完善通用求解器回归与前端交互

- 引入因果坐标内核、热流体恢复和递进长时回归\n- 完善正交连线、线桥、视图保持与结果曲线缩放\n- 补充依赖约束、CI、测试基线和北京时间更新日志
This commit is contained in:
lujingze committed 2026-08-18 06:42:07 +00:00
1 parent 143e8dd309
commit b435daecf2
65 files changed
+172136 -566

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
name: Solver regression
on:
push:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
pull_request:
paths:
- "app/simulation/**"
- "tests/**"
- "requirements.txt"
- "constraints/**"
- ".python-version"
- "README.md"
- ".github/workflows/solver-regression.yml"
schedule:
- cron: "17 3 * * 1-6"
- cron: "17 3 * * 0"
workflow_dispatch:
inputs:
suite:
description: Regression tier
required: true
default: quick
type: choice
options:
- quick
- historical
- main-long
case:
description: Longest main-model horizon (predecessors run first)
required: true
default: 0.2s
type: choice
options:
- 0.2s
- 1s
- 5s
- 10s
lane:
description: Output sampling lane
required: true
default: production
type: choice
options:
- solver-only
- production
concurrency:
group: solver-regression-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
quick:
if: >-
github.event_name == 'push' ||
github.event_name == 'pull_request' ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'quick')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
- name: Install reference dependencies
run: |
python -m pip install \
-r requirements.txt \
-c constraints/python312-direct.txt
python -m pip check
- name: Run solver foundation tests
env:
SYSTEM_SIMULATION_VERIFY_LOCKED_ENV: "1"
run: |
python -W error::ResourceWarning -m unittest \
tests.test_dependency_constraints \
tests.test_benchmark_regression \
tests.test_test_mql_8_regression \
tests.test_mql_full_branches_regression \
tests.test_pressure_flow_causal_execution \
tests.test_stream_pressure_block_solver \
tests.test_core_solver \
tests.test_supported_piston_tangent \
tests.test_three_piston_tangent \
tests.test_sparse_secant_jacobian \
tests.test_generic_jacobian_sparsity \
tests.test_generic_system_xml_simulation
historical-nightly:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 1-6') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'historical')
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
- name: Install reference dependencies
run: |
python -m pip install \
-r requirements.txt \
-c constraints/python312-direct.txt
python -m pip check
- name: Run 0.81 and 2.10 second historical regression
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_full_branches/manifest.json \
--lane solver-only \
--output artifacts/test-mql-full-branches.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: historical-solver-regression
path: artifacts/*.json
if-no-files-found: warn
main-periodic:
if: >-
(github.event_name == 'schedule' && github.event.schedule == '17 3 * * 0') ||
(github.event_name == 'workflow_dispatch' && inputs.suite == 'main-long')
runs-on: ubuntu-24.04
timeout-minutes: 180
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: .python-version
cache: pip
cache-dependency-path: |
requirements.txt
constraints/python312-direct.txt
- name: Install reference dependencies
run: |
python -m pip install \
-r requirements.txt \
-c constraints/python312-direct.txt
python -m pip check
- name: Run bounded progressive main-model regression
env:
REQUESTED_CASE: ${{ github.event_name == 'workflow_dispatch' && inputs.case || '10s' }}
REQUESTED_LANE: ${{ github.event_name == 'workflow_dispatch' && inputs.lane || 'production' }}
run: |
mkdir -p artifacts
python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
--lane "$REQUESTED_LANE" \
--case "$REQUESTED_CASE" \
--output artifacts/test-mql-8-progressive.json
- if: always()
uses: actions/upload-artifact@v4
with:
name: main-model-progressive-regression
path: artifacts/*.json
if-no-files-found: warn
+1
View File
@@ -0,0 +1 @@
3.12.3
+25 -5
View File
@@ -4,22 +4,42 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。
## 开发环境准备
后端依赖分别安装在平台对应的虚拟环境中。
后端统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本
`3.12.3`。`requirements.txt` 保留支持范围,
`constraints/python312-direct.txt` 固定应用直接依赖的参考版本。普通开发、性能复测和
CI 应同时使用两者,以避免同一代码在不同时间解析到不同的 NumPy、SciPy 或 Web
框架版本。
Windows:
```powershell
py -3 -m venv .venv-win
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
py -3.12 -m venv .venv-win
.\.venv-win\Scripts\python.exe -m pip install `
-r requirements.txt `
-c constraints/python312-direct.txt
.\.venv-win\Scripts\python.exe -m pip check
```
Linux:
```bash
python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements.txt
python3.12 -m venv .venv
./.venv/bin/python -m pip install \
-r requirements.txt \
-c constraints/python312-direct.txt
./.venv/bin/python -m pip check
```
约束文件只固定代码直接导入或启动的 `fastapi`、`lxml`、`numpy`、`pydantic`、
`scipy` 和 `uvicorn`。`uvicorn[standard]` 的可选传递依赖包含平台差异,因此仍由 pip
按目标平台解析;这套方案固定求解器和接口层的主要版本,但不是带 wheel 哈希的逐位
相同发布锁。若要测试 `requirements.txt` 声明的兼容范围,可显式省略 `-c`,但这类
结果不应与受约束环境的性能数据直接比较。
升级参考版本时,应在干净的 Python 3.12 虚拟环境中同时安装范围文件和约束文件,
运行 `pip check` 与后端测试,再更新约束;不要从单个平台的 `pip freeze` 直接复制
所有传递依赖。
前端使用 Vite 8,需要 Node.js `20.19+` 或 `22.12+`。首次启动前安装前端依赖。
Windows(PowerShell,使用仓库内的便携 Node.js):
File diff suppressed because it is too large. Load diff
+17 -1
View File
@@ -189,6 +189,15 @@ class AmesimPnl00r(AlgebraicComponent):
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
# A zero-volume two-port transports its stream outflow from the
# opposite connection, so ``port.h_outflow`` is deliberately crossed.
# Pressure loss, however, needs the enthalpy arriving at the same-side
# upstream connection. Keep that reference separate from the public
# connector outflow state.
self._connected_h = {
"port_1": initial_h,
"port_2": initial_h,
}
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
@@ -219,7 +228,7 @@ class AmesimPnl00r(AlgebraicComponent):
return max(
self.medium.temperature_from_pressure_enthalpy(
max(port.p, 1.0),
port.h_outflow,
self._connected_h[port_name],
),
1.0,
)
@@ -388,9 +397,16 @@ class AmesimPnl00r(AlgebraicComponent):
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self._connected_h = dict(connected_h)
self.port_1.h_outflow = connected_h["port_2"]
self.port_2.h_outflow = connected_h["port_1"]
def update_flow_temperature_references(
self,
connected_h: Mapping[str, float],
) -> None:
self._connected_h = dict(connected_h)
class AmesimPnl0001(ThermodynamicVolumeComponent):
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
File diff suppressed because it is too large. Load diff
+476 -10
View File
@@ -31,6 +31,10 @@ PRESSURE_LOWER_BOUND_PA = 0.0
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_FAST_PATH"
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_EXECUTOR_V2"
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_COORDINATE_KERNEL"
)
CAUSAL_FAST_PATH_AUDIT_INTERVAL = 64
@@ -39,6 +43,20 @@ def _causal_fast_path_environment_enabled() -> bool:
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_executor_v2_environment_enabled() -> bool:
"""Return whether the allocation-light causal executor is enabled."""
value = os.getenv(CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_coordinate_kernel_environment_enabled() -> bool:
"""Return whether the canonical-coordinate causal kernel is enabled."""
value = os.getenv(CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
class AlgebraicSolveError(RuntimeError):
def __init__(
self,
@@ -120,6 +138,42 @@ class CausalEffortAssignment:
anchor: EffortAnchor
@dataclass(frozen=True)
class CausalEffortKernelTarget:
"""One canonical effort coordinate extracted from a residual evaluator."""
coordinate_index: int
assignment: CausalEffortAssignment
equation_index: int
equation_id: str
@dataclass(frozen=True)
class CausalEffortKernelEvaluation:
"""One component call shared by every state anchor that it owns."""
evaluate: Callable[[], tuple[float, ...]]
targets: tuple[CausalEffortKernelTarget, ...]
@dataclass(frozen=True)
class CausalEffortKernelStage:
"""Precompiled canonical coordinates and compatibility broadcasts."""
variable: str
assignments: tuple[tuple[int, CausalEffortAssignment], ...]
direct_targets: tuple[tuple[int, CausalEffortAssignment], ...]
component_evaluations: tuple[CausalEffortKernelEvaluation, ...]
@dataclass(frozen=True)
class CausalFlowKernelStage:
"""Map one existing dependency stage into the canonical workspace."""
stage: ExplicitFlowStage
coordinate_indices: tuple[int, ...]
@dataclass(frozen=True)
class ConnectionEquationEvaluation:
template: EquationResidual
@@ -366,6 +420,56 @@ class PressureFlowSolver:
self._causal_fast_path_environment_enabled = (
_causal_fast_path_environment_enabled()
)
self._causal_executor_v2_environment_enabled = (
_causal_executor_v2_environment_enabled()
)
self._causal_coordinate_kernel_environment_enabled = (
_causal_coordinate_kernel_environment_enabled()
)
self._causal_compiled_effort_unknown_count = sum(
len(assignment.members)
for assignments in self._causal_effort_plan_by_variable.values()
for assignment in assignments
)
self._causal_compiled_flow_assignment_count = sum(
len(stage.assignments) for stage in self._explicit_flow_plan
)
self._causal_external_effort_unknowns = tuple(
member
for variable in ("x", "v")
for assignment in self._causal_effort_plan_by_variable.get(
variable, ()
)
for member in assignment.members
)
self._causal_external_x_states = tuple(
unknown.state
for unknown in self._causal_external_effort_unknowns
if unknown.variable == "x"
)
self._causal_external_v_states = tuple(
unknown.state
for unknown in self._causal_external_effort_unknowns
if unknown.variable == "v"
)
(
self._causal_effort_kernel_by_variable,
self._causal_flow_kernel_plan,
self._causal_coordinate_values,
) = self._compile_causal_coordinate_kernel()
self._causal_logical_effort_coordinate_count = sum(
len(stage.assignments)
for stage in self._causal_effort_kernel_by_variable.values()
)
self._causal_eliminated_effort_alias_count = max(
self._causal_compiled_effort_unknown_count
- self._causal_logical_effort_coordinate_count,
0,
)
self._causal_compatibility_scatter_count = (
self._causal_compiled_effort_unknown_count
+ self._causal_compiled_flow_assignment_count
)
self._causal_runtime_disabled_reason: str | None = None
self._causal_audit_interval = CAUSAL_FAST_PATH_AUDIT_INTERVAL
self._causal_audit_required = True
@@ -374,7 +478,11 @@ class PressureFlowSolver:
self._causal_full_residual_audit_count = 0
self._causal_audit_failure_count = 0
self._causal_legacy_fallback_count = 0
self._causal_v2_fast_solve_count = 0
self._causal_v2_runtime_validation_failure_count = 0
self._causal_coordinate_fast_solve_count = 0
self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None
self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
@property
@@ -435,6 +543,25 @@ class PressureFlowSolver:
and self._causal_runtime_disabled_reason is None
)
@property
def causal_executor_v2_enabled(self) -> bool:
"""Whether this run may use the v2 executor (environment opt-out)."""
return (
self._causal_executor_v2_environment_enabled
and self.causal_fast_path_enabled
)
@property
def causal_coordinate_kernel_enabled(self) -> bool:
"""Whether the canonical-coordinate executor may run now."""
return (
self._causal_coordinate_kernel_environment_enabled
and self.causal_executor_v2_enabled
and bool(self._causal_coordinate_values)
)
def causal_execution_diagnostics(self) -> dict[str, object]:
disabled_reason = self._causal_runtime_disabled_reason
if not self._causal_fast_path_environment_enabled:
@@ -458,6 +585,39 @@ class PressureFlowSolver:
if last_verified is not None
else None
),
"executorV2Configured": self._causal_executor_v2_environment_enabled,
"executorV2Enabled": self.causal_executor_v2_enabled,
"executorV2FastSolveCount": self._causal_v2_fast_solve_count,
"executorV2RuntimeValidationFailureCount": (
self._causal_v2_runtime_validation_failure_count
),
"coordinateKernelConfigured": (
self._causal_coordinate_kernel_environment_enabled
),
"coordinateKernelEnabled": self.causal_coordinate_kernel_enabled,
"coordinateKernelFastSolveCount": (
self._causal_coordinate_fast_solve_count
),
"compiledEffortUnknownCount": (
self._causal_compiled_effort_unknown_count
),
"compiledFlowAssignmentCount": (
self._causal_compiled_flow_assignment_count
),
"compiledAssignmentCount": (
self._causal_compiled_effort_unknown_count
+ self._causal_compiled_flow_assignment_count
),
"logicalEffortCoordinateCount": (
self._causal_logical_effort_coordinate_count
),
"eliminatedEffortAliasCount": (
self._causal_eliminated_effort_alias_count
),
"canonicalCoordinateCount": len(self._causal_coordinate_values),
"compatibilityScatterCount": (
self._causal_compatibility_scatter_count
),
}
def request_causal_audit(self) -> None:
@@ -651,10 +811,163 @@ class PressureFlowSolver:
None,
)
def _compile_causal_coordinate_kernel(
self,
) -> tuple[
dict[str, CausalEffortKernelStage],
tuple[CausalFlowKernelStage, ...],
list[float],
]:
"""Compile independent coordinates without changing public port state.
``PortState`` remains the compatibility surface consumed by component
methods. The workspace stores one value per proven effort equality
group and one per explicit flow assignment; compatibility aliases are
populated only after every target in an effort stage has been checked.
"""
if not self._causal_fast_path_eligible:
return {}, (), []
equation_index_by_id = {
equation.id: index
for index, equation in enumerate(self._equation_templates)
}
effort_stages: dict[str, CausalEffortKernelStage] = {}
next_coordinate = 0
for variable in ("p", "x", "v"):
assignments = self._causal_effort_plan_by_variable.get(variable, ())
indexed_assignments = tuple(
(next_coordinate + offset, assignment)
for offset, assignment in enumerate(assignments)
)
next_coordinate += len(indexed_assignments)
direct_targets: list[tuple[int, CausalEffortAssignment]] = []
targets_by_component: dict[
int,
list[CausalEffortKernelTarget],
] = {}
component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {}
for coordinate_index, assignment in indexed_assignments:
equation_index = equation_index_by_id[
assignment.anchor.equation_id
]
kind, evaluation_plan, source = (
self._equation_evaluation_locations[equation_index]
)
if kind != "component":
direct_targets.append((coordinate_index, assignment))
continue
component_plan = evaluation_plan
key = id(component_plan)
component_evaluators[key] = component_plan.evaluate
targets_by_component.setdefault(key, []).append(
CausalEffortKernelTarget(
coordinate_index=coordinate_index,
assignment=assignment,
equation_index=source,
equation_id=assignment.anchor.equation_id,
)
)
effort_stages[variable] = CausalEffortKernelStage(
variable=variable,
assignments=indexed_assignments,
direct_targets=tuple(direct_targets),
component_evaluations=tuple(
CausalEffortKernelEvaluation(
evaluate=component_evaluators[key],
targets=tuple(targets),
)
for key, targets in targets_by_component.items()
),
)
flow_stages: list[CausalFlowKernelStage] = []
for stage in self._explicit_flow_plan:
coordinate_indices = tuple(
range(next_coordinate, next_coordinate + len(stage.assignments))
)
next_coordinate += len(stage.assignments)
flow_stages.append(
CausalFlowKernelStage(
stage=stage,
coordinate_indices=coordinate_indices,
)
)
return effort_stages, tuple(flow_stages), [0.0] * next_coordinate
@staticmethod
def _read_effort_anchor(assignment: CausalEffortAssignment) -> float:
state = assignment.anchor.unknown.state
if assignment.variable == "p":
return state.p
if assignment.variable == "x":
return state.x
return state.v
@staticmethod
def _scatter_effort_assignment(
assignment: CausalEffortAssignment,
value: float,
) -> None:
if assignment.variable == "p":
for unknown in assignment.members:
unknown.state.p = value
return
if assignment.variable == "x":
for unknown in assignment.members:
unknown.state.x = value
return
for unknown in assignment.members:
unknown.state.v = value
def _execute_causal_coordinate_effort_plan(
self,
variables: tuple[str, ...],
) -> bool:
"""Evaluate canonical effort coordinates in component-sized batches."""
workspace = self._causal_coordinate_values
for variable in variables:
stage = self._causal_effort_kernel_by_variable.get(variable)
if stage is None:
return False
for coordinate_index, assignment in stage.direct_targets:
workspace[coordinate_index] = (
self._read_effort_anchor(assignment)
- assignment.anchor.evaluate()
)
for evaluation in stage.component_evaluations:
equation_values = evaluation.evaluate()
for target in evaluation.targets:
if target.equation_index >= len(equation_values):
raise RuntimeError(
"Compiled algebraic equation disappeared at runtime: "
f"{target.equation_id}."
)
workspace[target.coordinate_index] = (
self._read_effort_anchor(target.assignment)
- float(equation_values[target.equation_index])
)
for coordinate_index, assignment in stage.assignments:
target = workspace[coordinate_index]
if not isfinite(target) or (
variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
):
return False
for coordinate_index, assignment in stage.assignments:
self._scatter_effort_assignment(
assignment,
workspace[coordinate_index],
)
return True
def _execute_causal_effort_plan(
self,
variables: tuple[str, ...],
) -> bool:
if self.causal_coordinate_kernel_enabled:
return self._execute_causal_coordinate_effort_plan(variables)
for variable in variables:
assignments = self._causal_effort_plan_by_variable.get(variable)
if assignments is None:
@@ -685,15 +998,8 @@ class PressureFlowSolver:
self._causal_solves_since_audit = 0
self._causal_audit_required = False
self._causal_last_verified_diagnostics = diagnostics
def _causal_fast_diagnostics(
self,
) -> AlgebraicSolveDiagnostics:
verified = self._causal_last_verified_diagnostics
if verified is None:
raise RuntimeError("Causal execution has no verified residual baseline.")
return replace(
verified,
self._causal_cached_fast_diagnostics = replace(
diagnostics,
message=(
"Compiled causal pressure-flow program completed; residuals "
"reuse the latest full audit."
@@ -709,6 +1015,14 @@ class PressureFlowSolver:
causal_fast_path_used=True,
)
def _causal_fast_diagnostics(
self,
) -> AlgebraicSolveDiagnostics:
cached = self._causal_cached_fast_diagnostics
if cached is None:
raise RuntimeError("Causal execution has no verified residual baseline.")
return cached
def _build_jacobian_sparsity(self):
"""Compile the residual dependency contract into one CSR pattern.
@@ -1024,6 +1338,10 @@ class PressureFlowSolver:
unknown = sorted(set(variables) - set(self._effort_groups))
if unknown:
raise ValueError("Unsupported effort variables: " + ", ".join(unknown))
if self.causal_coordinate_kernel_enabled:
if self._execute_causal_coordinate_effort_plan(variables):
return
self._disable_causal_fast_path("nonFiniteCausalEffortAnchor")
for variable in variables:
self._seed_equal_effort(variable)
@@ -1835,6 +2153,121 @@ class PressureFlowSolver:
seeded_ids.add(assignment.unknown.id)
return seeded_ids
def _execute_compiled_causal_flow_plan(self) -> str | None:
"""Execute the compile-proven full flow plan without coverage sets."""
if self.causal_coordinate_kernel_enabled:
return self._execute_causal_coordinate_flow_plan()
# Position and velocity are propagated by the mechanical reducer
# before the pressure-only causal solve. They are therefore not
# rewritten below, but remain part of the compiled algebraic contract.
# Validate that small external boundary explicitly instead of restoring
# the legacy scan over every pressure/flow/force unknown.
if any(
not isfinite(unknown.read())
for unknown in self._causal_external_effort_unknowns
):
return "nonFiniteCausalExternalEffort"
reset_unknowns = self._explicit_flow_unknowns_by_variables[
frozenset(("f", "m_flow"))
]
for unknown in reset_unknowns:
unknown.write(0.0)
for stage in self._explicit_flow_plan:
try:
values = self._evaluate_explicit_flow_stage(stage)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, ValueError) as exc:
return f"causalFlowEvaluationFailed:{type(exc).__name__}"
if len(values) != len(stage.assignments):
return "causalFlowAssignmentCountMismatch"
for assignment, target_value in zip(stage.assignments, values):
if not isfinite(target_value):
return "nonFiniteCausalFlowAssignment"
assignment.unknown.write(target_value)
return None
def _execute_causal_coordinate_flow_plan(self) -> str | None:
"""Run flow stages through reusable canonical coordinates."""
if any(not isfinite(state.x) for state in self._causal_external_x_states):
return "nonFiniteCausalExternalEffort"
if any(not isfinite(state.v) for state in self._causal_external_v_states):
return "nonFiniteCausalExternalEffort"
return self._execute_causal_coordinate_flow_stages(
self._causal_flow_kernel_plan,
self._causal_coordinate_values,
)
@staticmethod
def _execute_causal_coordinate_flow_stages(
kernel_plan: tuple[CausalFlowKernelStage, ...],
workspace: list[float],
) -> str | None:
"""Execute proven flow stages without per-call result containers."""
# Residual-based explicit assignments use ``-residual`` and therefore
# require their target coordinate to be zero. Keep this compatibility
# initialization until a component exposes a proven direct target op.
for kernel_stage in kernel_plan:
for assignment in kernel_stage.stage.assignments:
if assignment.unknown.variable == "m_flow":
assignment.unknown.state.m_flow = 0.0
else:
assignment.unknown.state.f = 0.0
for kernel_stage in kernel_plan:
stage = kernel_stage.stage
coordinate_indices = kernel_stage.coordinate_indices
if len(coordinate_indices) != len(stage.assignments):
return "causalFlowAssignmentCountMismatch"
try:
for assignment_index, evaluate in stage.direct_evaluations:
workspace[coordinate_indices[assignment_index]] = float(
evaluate()
)
for evaluation in stage.component_evaluations:
equation_values = evaluation.evaluate()
for assignment_index, equation_index, equation_id in zip(
evaluation.assignment_indices,
evaluation.equation_indices,
evaluation.equation_ids,
):
if equation_index >= len(equation_values):
raise RuntimeError(
"Compiled algebraic equation disappeared at "
f"runtime: {equation_id}."
)
workspace[coordinate_indices[assignment_index]] = (
0.0 - float(equation_values[equation_index])
)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, ValueError) as exc:
return f"causalFlowEvaluationFailed:{type(exc).__name__}"
for assignment, coordinate_index in zip(
stage.assignments,
coordinate_indices,
):
target_value = workspace[coordinate_index]
if not isfinite(target_value):
return "nonFiniteCausalFlowAssignment"
for assignment, coordinate_index in zip(
stage.assignments,
coordinate_indices,
):
target_value = workspace[coordinate_index]
if assignment.unknown.variable == "m_flow":
assignment.unknown.state.m_flow = target_value
else:
assignment.unknown.state.f = target_value
return None
def _build_closed_resistance_pressure_plan(
self,
) -> tuple[ClosedResistancePressureBinding, ...]:
@@ -2088,6 +2521,9 @@ class PressureFlowSolver:
causal_audit_due = (
self._causal_audit_is_due() if causal_candidate else False
)
causal_v2_candidate = (
causal_candidate and self._causal_executor_v2_environment_enabled
)
for component in self._causal_contact_components:
component.clear_causal_contact()
@@ -2100,17 +2536,43 @@ class PressureFlowSolver:
self._disable_causal_fast_path("nonFiniteCausalEffortAnchor")
causal_candidate = False
causal_audit_due = False
causal_v2_candidate = False
self._seed_equal_efforts(effort_variables)
else:
self._seed_equal_efforts(effort_variables)
seeded_flow_ids: set[str] | None = None
contact_bindings: tuple[UnilateralContactBinding, ...] = ()
if causal_v2_candidate:
v2_failure_reason = self._execute_compiled_causal_flow_plan()
if v2_failure_reason is not None:
self._causal_v2_runtime_validation_failure_count += 1
self._causal_legacy_fallback_count += 1
self._disable_causal_fast_path(v2_failure_reason)
causal_candidate = False
causal_audit_due = False
causal_v2_candidate = False
# Rebuild the ordinary seed from scratch in the same solve.
# A partial compiled stage must never influence fallback.
self._seed_equal_efforts(effort_variables)
if not causal_v2_candidate:
self._seed_closed_resistance_pressures()
self._seed_resistance_pnl0001_series_pressures()
seeded_flow_ids = self._solve_explicit_flow_unknowns()
contact_bindings = self._seed_unilateral_contacts()
if contact_bindings:
seeded_flow_ids.update(self._solve_explicit_flow_unknowns(("f",)))
seeded_flow_ids.update(
self._solve_explicit_flow_unknowns(("f",))
)
self._refresh_unilateral_contacts(contact_bindings)
if causal_candidate:
if causal_v2_candidate:
# Compilation proves a disjoint, complete effort/flow
# partition. The v2 executors validate each produced value,
# so no coverage set or full unknown scan is needed here.
causal_unknowns_are_feasible = True
else:
assert seeded_flow_ids is not None
causal_unknowns_are_feasible = (
not contact_bindings
and seeded_flow_ids == self._causal_flow_unknown_ids
@@ -2131,6 +2593,10 @@ class PressureFlowSolver:
elif not causal_audit_due:
diagnostics = self._causal_fast_diagnostics()
self._causal_fast_solve_count += 1
if causal_v2_candidate:
self._causal_v2_fast_solve_count += 1
if self.causal_coordinate_kernel_enabled:
self._causal_coordinate_fast_solve_count += 1
self._causal_solves_since_audit += 1
self.last_diagnostics = diagnostics
return diagnostics
+177 -2
View File
@@ -9,6 +9,7 @@ from app.simulation.solvers.algebraic import (
PRESSURE_LOWER_BOUND_PA,
AlgebraicSolveDiagnostics,
AlgebraicUnknown,
CausalFlowKernelStage,
ExplicitFlowStage,
PressureFlowSolver,
)
@@ -237,6 +238,27 @@ class StreamPressureBlockSolver:
)
for stage in pressure_flow_solver._explicit_flow_plan
)
secondary_coordinate = 0
secondary_kernel_plan: list[CausalFlowKernelStage] = []
for stage in self._selected_explicit_flow_plan:
coordinate_indices = tuple(
range(
secondary_coordinate,
secondary_coordinate + len(stage.assignments),
)
)
secondary_coordinate += len(stage.assignments)
secondary_kernel_plan.append(
CausalFlowKernelStage(
stage=stage,
coordinate_indices=coordinate_indices,
)
)
self._causal_secondary_flow_kernel_plan = tuple(secondary_kernel_plan)
self._causal_secondary_coordinate_values = [0.0] * secondary_coordinate
self._causal_v2_entry_values = [0.0] * len(
self._selected_flow_unknowns
)
self._selected_equation_evaluation = (
self._compile_selected_equation_evaluation()
if self.blocks
@@ -259,7 +281,11 @@ class StreamPressureBlockSolver:
self._causal_full_residual_audit_count = 0
self._causal_audit_failure_count = 0
self._causal_legacy_fallback_count = 0
self._causal_v2_fast_solve_count = 0
self._causal_v2_runtime_validation_failure_count = 0
self._causal_coordinate_fast_solve_count = 0
self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None
self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None
@property
def available(self) -> bool:
@@ -273,6 +299,13 @@ class StreamPressureBlockSolver:
and self.pressure_flow_solver.causal_fast_path_enabled
)
@property
def causal_executor_v2_enabled(self) -> bool:
return (
self.causal_fast_path_enabled
and self.pressure_flow_solver._causal_executor_v2_environment_enabled
)
def request_causal_audit(self) -> None:
self._causal_audit_required = True
@@ -280,7 +313,9 @@ class StreamPressureBlockSolver:
parent = self.pressure_flow_solver.causal_execution_diagnostics()
disabled_reason = self._causal_runtime_disabled_reason
if not bool(parent["enabled"]):
disabled_reason = str(parent["disabledReason"] or "parentCausalPathDisabled")
disabled_reason = str(
parent["disabledReason"] or "parentCausalPathDisabled"
)
elif not self._causal_fast_path_eligible:
disabled_reason = self._causal_fast_path_fallback_reason
verified = self._causal_last_verified_diagnostics
@@ -298,6 +333,34 @@ class StreamPressureBlockSolver:
"lastVerifiedMaxScaledResidual": (
verified.max_scaled_residual if verified is not None else None
),
"executorV2Configured": (
self.pressure_flow_solver._causal_executor_v2_environment_enabled
),
"executorV2Enabled": self.causal_executor_v2_enabled,
"executorV2FastSolveCount": self._causal_v2_fast_solve_count,
"executorV2RuntimeValidationFailureCount": (
self._causal_v2_runtime_validation_failure_count
),
"coordinateKernelConfigured": (
self.pressure_flow_solver._causal_coordinate_kernel_environment_enabled
),
"coordinateKernelEnabled": (
self.causal_executor_v2_enabled
and self.pressure_flow_solver.causal_coordinate_kernel_enabled
),
"coordinateKernelFastSolveCount": (
self._causal_coordinate_fast_solve_count
),
"compiledEffortUnknownCount": len(
self._causal_effort_entry_positions
),
"compiledFlowAssignmentCount": len(
self._causal_expected_flow_equation_ids
),
"compiledAssignmentCount": (
len(self._causal_effort_entry_positions)
+ len(self._causal_expected_flow_equation_ids)
),
}
def _disable_causal_fast_path(self, reason: str) -> None:
@@ -405,6 +468,28 @@ class StreamPressureBlockSolver:
self._causal_solves_since_audit = 0
self._causal_audit_required = False
self._causal_last_verified_diagnostics = diagnostics
self._causal_cached_fast_diagnostics = replace(
diagnostics,
message=(
"Compiled causal stream-pressure block completed; residuals "
"reuse the latest full audit."
),
evaluations=0,
residual_evaluations=0,
dense_fallback_used=False,
nonlinear_block_count=0,
nonlinear_block_unknown_count=0,
block_fallback_used=False,
block_fallback_reason=None,
residual_verified_this_solve=False,
causal_fast_path_used=True,
)
def _causal_v2_fast_diagnostics(self) -> AlgebraicSolveDiagnostics:
cached = self._causal_cached_fast_diagnostics
if cached is None:
raise RuntimeError("Stream causal execution has no residual audit.")
return cached
def _causal_fast_diagnostics(
self,
@@ -788,6 +873,45 @@ class StreamPressureBlockSolver:
unknown.write(entry_values[position])
return frozenset(seeded_equation_ids)
def _execute_compiled_secondary_flow_plan(self) -> str | None:
"""Execute selected flow assignments without equation-id sets."""
if self.pressure_flow_solver.causal_coordinate_kernel_enabled:
failure = (
self.pressure_flow_solver._execute_causal_coordinate_flow_stages(
self._causal_secondary_flow_kernel_plan,
self._causal_secondary_coordinate_values,
)
)
if failure == "causalFlowAssignmentCountMismatch":
return "causalSecondaryFlowAssignmentCountMismatch"
if failure == "nonFiniteCausalFlowAssignment":
return "nonFiniteCausalSecondaryFlowAssignment"
if failure and failure.startswith("causalFlowEvaluationFailed:"):
return "causalSecondaryFlowEvaluationFailed:" + failure.rsplit(
":", 1
)[-1]
return failure
for unknown in self._selected_flow_unknowns:
unknown.write(0.0)
for stage in self._selected_explicit_flow_plan:
try:
values = self.pressure_flow_solver._evaluate_explicit_flow_stage(
stage
)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, ValueError) as exc:
return f"causalSecondaryFlowEvaluationFailed:{type(exc).__name__}"
if len(values) != len(stage.assignments):
return "causalSecondaryFlowAssignmentCountMismatch"
for assignment, target_value in zip(stage.assignments, values):
if not isfinite(target_value):
return "nonFiniteCausalSecondaryFlowAssignment"
assignment.unknown.write(target_value)
return None
@staticmethod
def _equation_scales_from_specs(
specs: tuple[_EquationScaleSpec, ...],
@@ -1083,11 +1207,62 @@ class StreamPressureBlockSolver:
scale_context: Mapping[str, float] | None = None,
) -> StreamBlockSolveResult:
solver = self.pressure_flow_solver
context = dict(scale_context or solver.scale_context())
causal_candidate = self.causal_fast_path_enabled
causal_audit_due = (
self._causal_audit_is_due() if causal_candidate else False
)
causal_v2_candidate = (
causal_candidate
and solver._causal_executor_v2_environment_enabled
and not causal_audit_due
)
if causal_v2_candidate:
# The secondary causal proof rejects every special pressure seed,
# so this executor mutates selected flow coordinates only. Keep
# the minimal transactional snapshot for the rare fallback path.
v2_entry_values = self._causal_v2_entry_values
for position, unknown in enumerate(self._selected_flow_unknowns):
v2_entry_values[position] = unknown.state.m_flow
def restore_v2_entry_mutations() -> None:
for unknown, value in zip(
self._selected_flow_unknowns,
v2_entry_values,
):
unknown.state.m_flow = value
try:
v2_failure_reason = (
self._execute_compiled_secondary_flow_plan()
)
except BaseException:
restore_v2_entry_mutations()
raise
if v2_failure_reason is None:
diagnostics = self._causal_v2_fast_diagnostics()
self._causal_fast_solve_count += 1
self._causal_v2_fast_solve_count += 1
if solver.causal_coordinate_kernel_enabled:
self._causal_coordinate_fast_solve_count += 1
self._causal_solves_since_audit += 1
selected = self._selected_equation_evaluation
assert selected is not None
return StreamBlockSolveResult(
diagnostics=(diagnostics,),
scopes=(selected.scope_components,),
used_global_fallback=False,
)
restore_v2_entry_mutations()
self._causal_v2_runtime_validation_failure_count += 1
self._causal_legacy_fallback_count += 1
self._disable_causal_fast_path(v2_failure_reason)
causal_candidate = False
causal_audit_due = False
# Keep scale construction and the full mutation snapshot off the v2
# success path. Callers may still precompute a shared scale mapping;
# avoiding that producer requires a later Generic-system API change.
context = dict(scale_context or solver.scale_context())
entry_values = tuple(
unknown.read() for unknown in self._entry_mutated_unknowns
)
+783
View File
@@ -0,0 +1,783 @@
"""Executable reference IR for compile-proven causal algebraic programs.
The IR eliminates duplicate *logical* effort coordinates, but intentionally
keeps a compatibility scatter map to existing ``PortState`` objects. Stream
propagation, derivatives, and result collection still consume those objects;
this is a reference for a future flat backend, not physical slot deletion.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass, replace
from enum import StrEnum
from hashlib import sha256
import json
from math import isfinite
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
import numpy as np
CAUSAL_NUMERIC_IR_SCHEMA_VERSION = 1
PRESSURE_LOWER_BOUND_PA = 0.0
class CausalIROpcode(StrEnum):
EFFORT_BROADCAST = "effort_broadcast"
EFFORT_DIRECT_RESIDUAL = "effort_direct_residual"
EFFORT_COMPONENT_RESIDUAL = "effort_component_residual"
FLOW_DIRECT = "flow_direct"
FLOW_COMPONENT_RESIDUAL = "flow_component_residual"
@dataclass(frozen=True, slots=True)
class CausalIRCompatibilitySlot:
slot: int
id: str
variable: str
@dataclass(frozen=True, slots=True)
class CausalIRCanonicalSlot:
slot: int
id: str
variable: str
kind: str
@dataclass(frozen=True, slots=True)
class CausalIREffortOperation:
opcode: CausalIROpcode
variable: str
result_slot: int
anchor_compatibility_slot: int
scatter_compatibility_slots: tuple[int, ...]
equation_id: str
@dataclass(frozen=True, slots=True)
class CausalIREffortEvaluation:
opcode: CausalIROpcode
output_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
equation_ids: tuple[str, ...]
evaluator_slot: int
@dataclass(frozen=True, slots=True)
class CausalIREffortStage:
variable: str
operations: tuple[CausalIREffortOperation, ...]
evaluations: tuple[CausalIREffortEvaluation, ...]
@dataclass(frozen=True, slots=True)
class CausalIRFlowOperation:
opcode: CausalIROpcode
output_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
equation_ids: tuple[str, ...]
evaluator_slot: int
@dataclass(frozen=True, slots=True)
class CausalIRFlowStage:
target_slots: tuple[int, ...]
scatter_compatibility_slots: tuple[int, ...]
equation_ids: tuple[str, ...]
operations: tuple[CausalIRFlowOperation, ...]
@dataclass(frozen=True, slots=True)
class CausalIRProgram:
"""Immutable callback-free structure used as the backend cache key."""
schema_version: int
canonical_slots: tuple[CausalIRCanonicalSlot, ...]
compatibility_slots: tuple[CausalIRCompatibilitySlot, ...]
reset_compatibility_slots: tuple[int, ...]
external_effort_compatibility_slots: tuple[int, ...]
effort_stages: tuple[CausalIREffortStage, ...]
flow_stages: tuple[CausalIRFlowStage, ...]
structural_signature: str
@property
def assignment_count(self) -> int:
return len(self.canonical_slots)
@property
def effort_group_count(self) -> int:
return sum(len(stage.operations) for stage in self.effort_stages)
@property
def flow_assignment_count(self) -> int:
return sum(len(stage.target_slots) for stage in self.flow_stages)
@property
def effort_scatter_count(self) -> int:
return sum(
len(operation.scatter_compatibility_slots)
for stage in self.effort_stages
for operation in stage.operations
)
@property
def eliminated_effort_replica_count(self) -> int:
return self.effort_scatter_count - self.effort_group_count
@property
def maximum_effort_stage_width(self) -> int:
return max((len(stage.operations) for stage in self.effort_stages), default=0)
@property
def maximum_flow_stage_width(self) -> int:
return max((len(stage.target_slots) for stage in self.flow_stages), default=0)
def structural_dict(self) -> dict[str, object]:
return {
"schemaVersion": self.schema_version,
"canonicalSlots": [
{
"slot": item.slot,
"id": item.id,
"variable": item.variable,
"kind": item.kind,
}
for item in self.canonical_slots
],
"compatibilitySlots": [
{"slot": item.slot, "id": item.id, "variable": item.variable}
for item in self.compatibility_slots
],
"resetCompatibilitySlots": list(self.reset_compatibility_slots),
"externalEffortCompatibilitySlots": list(
self.external_effort_compatibility_slots
),
"effortStages": [
{
"variable": stage.variable,
"operations": [
{
"opcode": operation.opcode.value,
"resultSlot": operation.result_slot,
"anchorCompatibilitySlot": (
operation.anchor_compatibility_slot
),
"scatterCompatibilitySlots": list(
operation.scatter_compatibility_slots
),
"equationId": operation.equation_id,
}
for operation in stage.operations
],
"evaluations": [
{
"opcode": evaluation.opcode.value,
"outputIndices": list(evaluation.output_indices),
"equationIndices": list(evaluation.equation_indices),
"equationIds": list(evaluation.equation_ids),
"evaluatorSlot": evaluation.evaluator_slot,
}
for evaluation in stage.evaluations
],
}
for stage in self.effort_stages
],
"flowStages": [
{
"targetSlots": list(stage.target_slots),
"scatterCompatibilitySlots": list(
stage.scatter_compatibility_slots
),
"equationIds": list(stage.equation_ids),
"operations": [
{
"opcode": operation.opcode.value,
"outputIndices": list(operation.output_indices),
"equationIndices": list(operation.equation_indices),
"equationIds": list(operation.equation_ids),
"evaluatorSlot": operation.evaluator_slot,
}
for operation in stage.operations
],
}
for stage in self.flow_stages
],
}
def calculate_structural_signature(self) -> str:
payload = json.dumps(
self.structural_dict(),
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return sha256(payload).hexdigest()
@dataclass(frozen=True, slots=True)
class CausalIRBindings:
readers: tuple[Callable[[], float], ...]
writers: tuple[Callable[[float], None], ...]
evaluators: tuple[Callable[[], object], ...]
@dataclass(slots=True)
class CausalIRWorkspace:
structural_signature: str
canonical_values: "np.ndarray[Any, Any]"
effort_residuals: "np.ndarray[Any, Any]"
effort_written: "np.ndarray[Any, Any]"
flow_values: "np.ndarray[Any, Any]"
flow_written: "np.ndarray[Any, Any]"
transaction_values: "np.ndarray[Any, Any]"
@dataclass(frozen=True, slots=True)
class CausalIRExecutionResult:
success: bool
fallback_reason: str | None
structural_signature: str
effort_assignment_count: int
flow_assignment_count: int
completed_effort_stage_count: int
completed_flow_stage_count: int
rolled_back: bool
StageObserver = Callable[
[str, int, tuple[int, ...], tuple[float, ...]],
None,
]
@dataclass(frozen=True, slots=True)
class CausalNumericIR:
"""Bound reference IR; its normal path performs no full snapshot."""
program: CausalIRProgram
bindings: CausalIRBindings
def create_workspace(self) -> CausalIRWorkspace:
try:
import numpy as np
except ImportError as exc: # pragma: no cover
raise RuntimeError("The causal numeric reference IR requires NumPy.") from exc
return CausalIRWorkspace(
structural_signature=self.program.structural_signature,
canonical_values=np.empty(
max(len(self.program.canonical_slots), 1), dtype=np.float64
),
effort_residuals=np.empty(
max(self.program.maximum_effort_stage_width, 1), dtype=np.float64
),
effort_written=np.empty(
max(self.program.maximum_effort_stage_width, 1), dtype=np.bool_
),
flow_values=np.empty(
max(self.program.maximum_flow_stage_width, 1), dtype=np.float64
),
flow_written=np.empty(
max(self.program.maximum_flow_stage_width, 1), dtype=np.bool_
),
transaction_values=np.empty(
max(len(self.program.compatibility_slots), 1), dtype=np.float64
),
)
def execute(
self,
workspace: CausalIRWorkspace,
*,
effort_variables: tuple[str, ...] = ("p",),
transactional: bool = False,
stage_observer: StageObserver | None = None,
) -> CausalIRExecutionResult:
"""Interpret the IR; transactional snapshots are audit-only."""
program = self.program
bindings = self.bindings
signature = program.structural_signature
if workspace.structural_signature != signature:
raise ValueError("Causal IR workspace belongs to a different program.")
if len(bindings.readers) != len(program.compatibility_slots) or len(
bindings.writers
) != len(program.compatibility_slots):
raise ValueError("Causal IR compatibility binding count is inconsistent.")
if any(variable not in {"p", "x", "v"} for variable in effort_variables):
return CausalIRExecutionResult(
False, "unsupportedEffortVariable", signature, 0, 0, 0, 0, False
)
snapshot_count = 0
if transactional:
try:
for slot, reader in enumerate(bindings.readers):
workspace.transaction_values[slot] = float(reader())
snapshot_count += 1
except MemoryError:
raise
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
return CausalIRExecutionResult(
False,
f"slotReadFailed:{type(exc).__name__}",
signature,
0,
0,
0,
0,
False,
)
effort_count = 0
flow_count = 0
completed_effort_stages = 0
completed_flow_stages = 0
def failed(reason: str) -> CausalIRExecutionResult:
rolled_back = False
if transactional:
for slot in range(snapshot_count):
bindings.writers[slot](float(workspace.transaction_values[slot]))
rolled_back = True
return CausalIRExecutionResult(
False,
reason,
signature,
effort_count,
flow_count,
completed_effort_stages,
completed_flow_stages,
rolled_back,
)
selected_efforts = frozenset(effort_variables)
for stage_index, stage in enumerate(program.effort_stages):
if stage.variable not in selected_efforts:
continue
width = len(stage.operations)
workspace.effort_written[:width] = False
for evaluation in stage.evaluations:
try:
evaluated = bindings.evaluators[evaluation.evaluator_slot]()
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL:
output = evaluation.output_indices[0]
workspace.effort_residuals[output] = float(evaluated)
workspace.effort_written[output] = True
continue
if not hasattr(evaluated, "__len__"):
raise TypeError("component evaluator returned no sequence")
for output, equation in zip(
evaluation.output_indices, evaluation.equation_indices
):
if equation >= len(evaluated):
raise IndexError("component equation disappeared")
workspace.effort_residuals[output] = float(evaluated[equation])
workspace.effort_written[output] = True
except MemoryError:
raise
except (
ArithmeticError,
IndexError,
RuntimeError,
TypeError,
ValueError,
) as exc:
return failed(f"effortEvaluationFailed:{type(exc).__name__}")
if any(not bool(workspace.effort_written[index]) for index in range(width)):
return failed("effortEvaluationCoverageMismatch")
for output, operation in enumerate(stage.operations):
try:
anchor = float(bindings.readers[operation.anchor_compatibility_slot]())
target = anchor - float(workspace.effort_residuals[output])
except MemoryError:
raise
except (
ArithmeticError,
IndexError,
RuntimeError,
TypeError,
ValueError,
) as exc:
return failed(f"effortAssignmentFailed:{type(exc).__name__}")
if not isfinite(target) or (
stage.variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
):
return failed("nonFiniteOrInvalidEffortAssignment")
workspace.canonical_values[operation.result_slot] = target
for slot in operation.scatter_compatibility_slots:
bindings.writers[slot](target)
effort_count += 1
completed_effort_stages += 1
if stage_observer is not None:
try:
stage_observer(
f"effort:{stage.variable}",
stage_index,
tuple(item.result_slot for item in stage.operations),
tuple(
float(workspace.canonical_values[item.result_slot])
for item in stage.operations
),
)
except MemoryError:
raise
except Exception as exc:
return failed(f"stageObserverFailed:{type(exc).__name__}")
try:
external_finite = all(
isfinite(float(bindings.readers[slot]()))
for slot in program.external_effort_compatibility_slots
)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc:
return failed(f"externalEffortReadFailed:{type(exc).__name__}")
if not external_finite:
return failed("nonFiniteExternalEffort")
for slot in program.reset_compatibility_slots:
bindings.writers[slot](0.0)
for stage_index, stage in enumerate(program.flow_stages):
width = len(stage.target_slots)
workspace.flow_written[:width] = False
for operation in stage.operations:
try:
evaluated = bindings.evaluators[operation.evaluator_slot]()
if operation.opcode is CausalIROpcode.FLOW_DIRECT:
output = operation.output_indices[0]
workspace.flow_values[output] = float(evaluated)
workspace.flow_written[output] = True
continue
if not hasattr(evaluated, "__len__"):
raise TypeError("component evaluator returned no sequence")
for output, equation in zip(
operation.output_indices, operation.equation_indices
):
if equation >= len(evaluated):
raise IndexError("component equation disappeared")
# Targets are zero before the stage; preserve -residual.
workspace.flow_values[output] = -float(evaluated[equation])
workspace.flow_written[output] = True
except MemoryError:
raise
except (
ArithmeticError,
IndexError,
RuntimeError,
TypeError,
ValueError,
) as exc:
return failed(f"flowEvaluationFailed:{type(exc).__name__}")
if any(not bool(workspace.flow_written[index]) for index in range(width)):
return failed("flowAssignmentCoverageMismatch")
for output, (canonical, compatibility) in enumerate(
zip(stage.target_slots, stage.scatter_compatibility_slots)
):
target = float(workspace.flow_values[output])
if not isfinite(target):
return failed("nonFiniteFlowAssignment")
workspace.canonical_values[canonical] = target
bindings.writers[compatibility](target)
flow_count += 1
completed_flow_stages += 1
if stage_observer is not None:
try:
stage_observer(
"flow",
stage_index,
stage.target_slots,
tuple(float(workspace.flow_values[i]) for i in range(width)),
)
except MemoryError:
raise
except Exception as exc:
return failed(f"stageObserverFailed:{type(exc).__name__}")
return CausalIRExecutionResult(
True,
None,
signature,
effort_count,
flow_count,
completed_effort_stages,
completed_flow_stages,
False,
)
@dataclass(frozen=True, slots=True)
class CausalIRCompilation:
ir: CausalNumericIR | None
fallback_reason: str | None
@property
def supported(self) -> bool:
return self.ir is not None and self.fallback_reason is None
def _unsupported(reason: str) -> CausalIRCompilation:
return CausalIRCompilation(ir=None, fallback_reason=reason)
def _unique_slots(items: Iterable[int]) -> tuple[int, ...]:
return tuple(dict.fromkeys(int(item) for item in items))
def _compile_effort_evaluations(
operations: tuple[CausalIREffortOperation, ...],
anchor_evaluators: tuple[Callable[[], float], ...],
component_locations: dict[
str, tuple[object, Callable[[], tuple[float, ...]], int]
],
evaluators: list[Callable[[], object]],
) -> tuple[CausalIREffortEvaluation, ...]:
grouped: dict[int, list[tuple[int, int, str]]] = {}
component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {}
direct: list[tuple[int, Callable[[], float], str]] = []
for output, (operation, anchor_evaluate) in enumerate(
zip(operations, anchor_evaluators)
):
location = component_locations.get(operation.equation_id)
if location is None:
direct.append((output, anchor_evaluate, operation.equation_id))
continue
owner, evaluate, equation = location
key = id(owner)
component_callbacks[key] = evaluate
grouped.setdefault(key, []).append((output, equation, operation.equation_id))
compiled: list[CausalIREffortEvaluation] = []
for output, evaluate, equation_id in direct:
evaluator = len(evaluators)
evaluators.append(evaluate)
compiled.append(
CausalIREffortEvaluation(
CausalIROpcode.EFFORT_DIRECT_RESIDUAL,
(output,),
(),
(equation_id,),
evaluator,
)
)
for key, entries in grouped.items():
evaluator = len(evaluators)
evaluators.append(component_callbacks[key])
compiled.append(
CausalIREffortEvaluation(
CausalIROpcode.EFFORT_COMPONENT_RESIDUAL,
tuple(item[0] for item in entries),
tuple(item[1] for item in entries),
tuple(item[2] for item in entries),
evaluator,
)
)
return tuple(compiled)
def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
"""Lower a compile-proven global plan; unsupported plans fail closed."""
if not bool(getattr(solver, "_causal_fast_path_eligible", False)):
return _unsupported(
str(
getattr(solver, "_causal_fast_path_fallback_reason", None)
or "causalProofNotAvailable"
)
)
try:
unknowns = tuple(getattr(solver, "unknowns"))
effort_plan = getattr(solver, "_causal_effort_plan_by_variable")
flow_plan = tuple(getattr(solver, "_explicit_flow_plan"))
component_plan = tuple(getattr(solver, "_component_equation_plan"))
reset_unknowns = tuple(
getattr(solver, "_explicit_flow_unknowns_by_variables")[
frozenset(("f", "m_flow"))
]
)
external_unknowns = tuple(
getattr(solver, "_causal_external_effort_unknowns")
)
except (AttributeError, KeyError, TypeError):
return _unsupported("unsupportedCausalSolverContract")
unknown_ids = tuple(str(item.id) for item in unknowns)
if len(set(unknown_ids)) != len(unknown_ids):
return _unsupported("duplicateAlgebraicUnknown")
compatibility_slot_by_id = {
unknown_id: slot for slot, unknown_id in enumerate(unknown_ids)
}
compatibility_slots = tuple(
CausalIRCompatibilitySlot(slot, unknown_id, str(unknown.variable))
for slot, (unknown_id, unknown) in enumerate(zip(unknown_ids, unknowns))
)
readers = tuple(item.read for item in unknowns)
writers = tuple(item.write for item in unknowns)
evaluators: list[Callable[[], object]] = []
canonical_slots: list[CausalIRCanonicalSlot] = []
component_locations: dict[
str, tuple[object, Callable[[], tuple[float, ...]], int]
] = {}
try:
for plan in component_plan:
for equation, template in enumerate(plan.templates):
component_locations[str(template.id)] = (
plan.component,
plan.evaluate,
equation,
)
except (AttributeError, TypeError):
return _unsupported("unsupportedComponentEvaluationContract")
effort_stages: list[CausalIREffortStage] = []
try:
for variable in ("p", "x", "v"):
operations: list[CausalIREffortOperation] = []
anchors: list[Callable[[], float]] = []
for assignment in effort_plan[variable]:
result = len(canonical_slots)
equation_id = str(assignment.anchor.equation_id)
scatter = tuple(
compatibility_slot_by_id[item.id]
for item in assignment.members
)
if not scatter or len(set(scatter)) != len(scatter):
return _unsupported("invalidEffortScatterSlots")
canonical_slots.append(
CausalIRCanonicalSlot(
result,
f"effort:{variable}:{equation_id}",
variable,
"effort_group",
)
)
operations.append(
CausalIREffortOperation(
CausalIROpcode.EFFORT_BROADCAST,
variable,
result,
compatibility_slot_by_id[assignment.anchor.unknown.id],
scatter,
equation_id,
)
)
anchors.append(assignment.anchor.evaluate)
operation_tuple = tuple(operations)
effort_stages.append(
CausalIREffortStage(
variable,
operation_tuple,
_compile_effort_evaluations(
operation_tuple,
tuple(anchors),
component_locations,
evaluators,
),
)
)
except (AttributeError, KeyError, TypeError):
return _unsupported("unsupportedEffortPlanContract")
flow_stages: list[CausalIRFlowStage] = []
try:
for stage in flow_plan:
scatter = tuple(
compatibility_slot_by_id[item.unknown.id]
for item in stage.assignments
)
equation_ids = tuple(str(item.equation_id) for item in stage.assignments)
if len(set(scatter)) != len(scatter):
return _unsupported("duplicateFlowTargetInStage")
targets: list[int] = []
for assignment in stage.assignments:
target = len(canonical_slots)
targets.append(target)
canonical_slots.append(
CausalIRCanonicalSlot(
target,
f"flow:{assignment.unknown.id}",
str(assignment.unknown.variable),
"flow_assignment",
)
)
covered: list[int] = []
operations: list[CausalIRFlowOperation] = []
for output, evaluate in stage.direct_evaluations:
output = int(output)
evaluator = len(evaluators)
evaluators.append(evaluate)
operations.append(
CausalIRFlowOperation(
CausalIROpcode.FLOW_DIRECT,
(output,),
(),
(equation_ids[output],),
evaluator,
)
)
covered.append(output)
for evaluation in stage.component_evaluations:
evaluator = len(evaluators)
evaluators.append(evaluation.evaluate)
outputs = tuple(int(item) for item in evaluation.assignment_indices)
operations.append(
CausalIRFlowOperation(
CausalIROpcode.FLOW_COMPONENT_RESIDUAL,
outputs,
tuple(int(item) for item in evaluation.equation_indices),
tuple(str(item) for item in evaluation.equation_ids),
evaluator,
)
)
covered.extend(outputs)
if sorted(covered) != list(range(len(scatter))):
return _unsupported("flowStageEvaluationCoverageMismatch")
flow_stages.append(
CausalIRFlowStage(
tuple(targets), scatter, equation_ids, tuple(operations)
)
)
except (AttributeError, IndexError, KeyError, TypeError):
return _unsupported("unsupportedFlowPlanContract")
try:
reset_slots = _unique_slots(
compatibility_slot_by_id[item.id] for item in reset_unknowns
)
external_slots = _unique_slots(
compatibility_slot_by_id[item.id] for item in external_unknowns
)
except (AttributeError, KeyError):
return _unsupported("unknownCausalBoundarySlot")
flow_scatter = tuple(
item for stage in flow_stages for item in stage.scatter_compatibility_slots
)
if len(set(flow_scatter)) != len(flow_scatter):
return _unsupported("duplicateExplicitFlowAssignment")
if set(flow_scatter) != set(reset_slots):
return _unsupported("incompleteExplicitFlowCoverage")
program = CausalIRProgram(
CAUSAL_NUMERIC_IR_SCHEMA_VERSION,
tuple(canonical_slots),
compatibility_slots,
reset_slots,
external_slots,
tuple(effort_stages),
tuple(flow_stages),
"",
)
program = replace(
program, structural_signature=program.calculate_structural_signature()
)
return CausalIRCompilation(
CausalNumericIR(
program,
CausalIRBindings(readers, writers, tuple(evaluators)),
),
None,
)
+245 -24
View File
@@ -29,6 +29,8 @@ StateTransitionHandler = Callable[
]
_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64
_MAX_RECOVERABLE_RETRIES = 16
_RECOVERABLE_RETRY_FACTOR = 0.5
class IntegrationCancelled(Exception):
@@ -48,6 +50,29 @@ class SolveIVPConfig:
first_step: float | None = None
@dataclass(frozen=True)
class RecoverableRetryDiagnostics:
"""One recoverable trial failure and the step cap chosen for its retry."""
phase: Literal["constructor", "step", "solver-status"]
attempted_step: float
reason: str
next_max_step: float | None = None
next_first_step: float | None = None
def as_dict(self) -> dict[str, object]:
result: dict[str, object] = {
"phase": self.phase,
"attemptedStep": self.attempted_step,
"reason": self.reason,
}
if self.next_max_step is not None:
result["nextMaxStep"] = self.next_max_step
if self.next_first_step is not None:
result["nextFirstStep"] = self.next_first_step
return result
@dataclass(frozen=True)
class SolverSegmentDiagnostics:
"""Work performed by implicit solver instances inside one event segment."""
@@ -61,6 +86,7 @@ class SolverSegmentDiagnostics:
accepted_step_count: int = 0
solver_start_count: int = 0
state_transition_count: int = 0
state_transition_times: tuple[float, ...] = ()
recoverable_retry_count: int = 0
jacobian_evaluation_count: int = 0
jacobian_full_build_count: int = 0
@@ -72,9 +98,10 @@ class SolverSegmentDiagnostics:
exact_column_build_count: int = 0
exact_column_fallback_count: int = 0
jacobian_assembly_seconds: float = 0.0
recoverable_retries: tuple[RecoverableRetryDiagnostics, ...] = ()
def as_dict(self) -> dict[str, float | int]:
result: dict[str, float | int] = {
def as_dict(self) -> dict[str, object]:
result: dict[str, object] = {
"startTime": self.start_time,
"requestedStopTime": self.requested_stop_time,
"simulatedUntil": self.simulated_until,
@@ -86,6 +113,14 @@ class SolverSegmentDiagnostics:
"stateTransitionCount": self.state_transition_count,
"recoverableRetryCount": self.recoverable_retry_count,
}
if self.state_transition_times:
result["stateTransitionTimes"] = list(
self.state_transition_times
)
if self.recoverable_retries:
result["recoverableRetries"] = [
retry.as_dict() for retry in self.recoverable_retries
]
if (
self.jacobian_evaluation_count
or self.finite_difference_rhs_evaluation_count
@@ -143,6 +178,80 @@ def _jacobian_diagnostic_snapshot(
}
def _positive_finite_step(value: object) -> float | None:
if value is None:
return None
try:
candidate = abs(float(value))
except (TypeError, ValueError, OverflowError):
return None
return candidate if candidate > 0.0 and math.isfinite(candidate) else None
def _smallest_positive_finite_step(*values: object) -> float:
"""Return a conservative step bound from configuration candidates."""
candidates = [
candidate
for value in values
if (candidate := _positive_finite_step(value)) is not None
]
if not candidates:
raise ValueError("No positive finite integration step is available.")
return min(candidates)
def _solver_attempted_step(
solver: object,
*,
segment_max_step: float,
remaining_interval: float,
) -> float:
"""Snapshot the real trial scale before calling ``solver.step()``.
SciPy exposes the proposed step as ``h_abs``. ``step_size`` is the prior
accepted step, so it is only a fallback for solvers without a valid
``h_abs``; it must not reduce an otherwise valid failed-trial estimate.
"""
configured_cap = _smallest_positive_finite_step(
segment_max_step,
remaining_interval,
)
for attribute in ("h_abs", "step_size"):
try:
candidate = _positive_finite_step(
getattr(solver, attribute, None)
)
except Exception:
# A third-party OdeSolver may implement these as fragile
# properties. The configured cap remains a safe fallback.
continue
if candidate is not None:
return min(candidate, configured_cap)
return configured_cap
def _recoverable_retry_steps(
attempted_step: float,
*,
last_accepted_time: float,
) -> tuple[float, float] | None:
"""Return strictly smaller max/first steps, or None at machine precision."""
next_step = _RECOVERABLE_RETRY_FACTOR * attempted_step
minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0))
if (
not math.isfinite(next_step)
or next_step <= minimum_step
or next_step >= attempted_step
):
return None
# This first step is intentionally one-shot. Keeping it equal to the new
# cap makes both controls strictly smaller than the failed trial scale.
return next_step, next_step
@dataclass(frozen=True)
class ODESolution:
t: list[float]
@@ -751,13 +860,18 @@ def _integrate_scipy_stepwise(
segment_max_step = float(config.max_step)
recoverable_retry_count = 0
last_recoverable_error: RecoverableTrialStateError | None = None
retry_first_step: float | None = None
segment_nfev = 0
segment_njev = 0
segment_nlu = 0
segment_accepted_steps = 0
segment_solver_starts = 0
segment_state_transitions = 0
segment_state_transition_times: list[float] = []
segment_recoverable_retries = 0
segment_recoverable_retry_diagnostics: list[
RecoverableRetryDiagnostics
] = []
jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac)
while has_integration_interval and last_accepted_time < integration_end:
@@ -777,8 +891,8 @@ def _integrate_scipy_stepwise(
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
retry_first_step
if retry_first_step is not None
else config.first_step
)
if requested_first_step is not None:
@@ -786,8 +900,12 @@ def _integrate_scipy_stepwise(
requested_first_step,
integration_end - last_accepted_time,
)
try:
constructor_attempted_step = _smallest_positive_finite_step(
segment_max_step,
integration_end - last_accepted_time,
solver_options.get("first_step"),
)
start_segment = getattr(implicit_jac, "start_segment", None)
if start_segment is not None:
start_segment()
@@ -806,14 +924,33 @@ def _integrate_scipy_stepwise(
recoverable_retry_count += 1
segment_recoverable_retries += 1
last_recoverable_error = exc
next_step = 0.5 * segment_max_step
minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0))
if recoverable_retry_count > 16 or next_step <= minimum_step:
retry_steps = (
_recoverable_retry_steps(
constructor_attempted_step,
last_accepted_time=last_accepted_time,
)
if recoverable_retry_count <= _MAX_RECOVERABLE_RETRIES
else None
)
segment_recoverable_retry_diagnostics.append(
RecoverableRetryDiagnostics(
phase="constructor",
attempted_step=constructor_attempted_step,
reason=str(exc),
next_max_step=(
retry_steps[0] if retry_steps is not None else None
),
next_first_step=(
retry_steps[1] if retry_steps is not None else None
),
)
)
if retry_steps is None:
status = "failed"
message = str(exc)
error = exc
break
segment_max_step = next_step
segment_max_step, retry_first_step = retry_steps
continue
except Exception as exc:
status = "failed"
@@ -836,6 +973,13 @@ def _integrate_scipy_stepwise(
step_start_time = last_accepted_time
step_start_state = list(last_accepted_state)
try:
attempted_step = _solver_attempted_step(
solver,
segment_max_step=segment_max_step,
remaining_interval=(
integration_end - last_accepted_time
),
)
step_message = solver.step()
except IntegrationCancelled:
status = "cancelled"
@@ -847,17 +991,38 @@ def _integrate_scipy_stepwise(
recoverable_retry_count += 1
segment_recoverable_retries += 1
last_recoverable_error = exc
attempted_step = segment_max_step
next_step = 0.5 * attempted_step
minimum_step = 64.0 * math.ulp(
max(abs(last_accepted_time), 1.0)
retry_steps = (
_recoverable_retry_steps(
attempted_step,
last_accepted_time=last_accepted_time,
)
if recoverable_retry_count > 16 or next_step <= minimum_step:
if recoverable_retry_count
<= _MAX_RECOVERABLE_RETRIES
else None
)
segment_recoverable_retry_diagnostics.append(
RecoverableRetryDiagnostics(
phase="step",
attempted_step=attempted_step,
reason=str(exc),
next_max_step=(
retry_steps[0]
if retry_steps is not None
else None
),
next_first_step=(
retry_steps[1]
if retry_steps is not None
else None
),
)
)
if retry_steps is None:
status = "failed"
message = str(exc)
error = exc
break
segment_max_step = next_step
segment_max_step, retry_first_step = retry_steps
restart_after_recoverable = True
break
except Exception as exc:
@@ -871,21 +1036,62 @@ def _integrate_scipy_stepwise(
if last_recoverable_error is not None:
recoverable_retry_count += 1
segment_recoverable_retries += 1
next_step = 0.5 * segment_max_step
minimum_step = 64.0 * math.ulp(
max(abs(last_accepted_time), 1.0)
retry_steps = (
_recoverable_retry_steps(
attempted_step,
last_accepted_time=last_accepted_time,
)
if (
recoverable_retry_count <= 16
and next_step > minimum_step
):
segment_max_step = next_step
if recoverable_retry_count
<= _MAX_RECOVERABLE_RETRIES
else None
)
failure_reason = str(
step_message or last_recoverable_error
)
segment_recoverable_retry_diagnostics.append(
RecoverableRetryDiagnostics(
phase="solver-status",
attempted_step=attempted_step,
reason=failure_reason,
next_max_step=(
retry_steps[0]
if retry_steps is not None
else None
),
next_first_step=(
retry_steps[1]
if retry_steps is not None
else None
),
)
)
if retry_steps is not None:
segment_max_step, retry_first_step = retry_steps
restart_after_recoverable = True
break
status = "failed"
message = str(step_message or "Integration step failed.")
break
# A returned running/finished status means this step was
# accepted. Any prior recoverable failure is now historical:
# it must not influence an event restart or an ordinary later
# solver failure. The reduced cap is local to the failed
# trial: after one accepted retry step, let this solver grow
# adaptively again and ensure a later event restart receives
# the configured maximum. The retry-specific first step is
# likewise strictly one-shot.
if retry_first_step is not None:
segment_max_step = float(config.max_step)
try:
solver.max_step = segment_max_step
except (AttributeError, TypeError, ValueError):
# Third-party OdeSolver-compatible test doubles may not
# expose a writable cap. SciPy's supported solvers do.
pass
last_recoverable_error = None
retry_first_step = None
recoverable_retry_count = 0
segment_accepted_steps += 1
step_end_time = float(solver.t)
step_end_state = [float(value) for value in solver.y]
@@ -940,6 +1146,9 @@ def _integrate_scipy_stepwise(
if transition is not None:
segment_state_transitions += 1
segment_state_transition_times.append(
float(transition.time)
)
try:
same_time_transition_count = (
_next_same_time_transition_count(
@@ -997,7 +1206,6 @@ def _integrate_scipy_stepwise(
last_accepted_time = step_end_time
last_accepted_state = step_end_state
recoverable_retry_count = 0
reported_time = (
float(segment_end)
if is_breakpoint and solver.status == "finished"
@@ -1057,7 +1265,13 @@ def _integrate_scipy_stepwise(
accepted_step_count=segment_accepted_steps,
solver_start_count=segment_solver_starts,
state_transition_count=segment_state_transitions,
state_transition_times=tuple(
segment_state_transition_times
),
recoverable_retry_count=segment_recoverable_retries,
recoverable_retries=tuple(
segment_recoverable_retry_diagnostics
),
jacobian_evaluation_count=int(
jacobian_work["jacobianEvaluationCount"]
),
@@ -1150,6 +1364,7 @@ def integrate_ode(
state_transition_handler: StateTransitionHandler | None = None,
jac_sparsity=None,
jac: JacobianCallable | None = None,
recoverable_trial_retries: bool = False,
):
"""Integrate an ODE, optionally restarting at equation discontinuities.
@@ -1161,6 +1376,11 @@ def integrate_ode(
interpolant. When it returns a transition, samples before the event retain
the pre-event trajectory, the reset state is stored at the event, and a fresh
solver continues from that state.
``recoverable_trial_retries`` opts an eventless/cancellation-free caller
into the stepwise path so a ``RecoverableTrialStateError`` can rebuild the
solver from its last accepted state. It defaults to false to preserve the
direct ``solve_ivp`` path for ordinary callers.
"""
if (
@@ -1209,6 +1429,7 @@ def integrate_ode(
cancel_check is not None
or normalized_breakpoints
or state_transition_handler is not None
or recoverable_trial_retries
):
return _integrate_scipy_stepwise(
rhs,
+20
View File
@@ -60,6 +60,16 @@ class StreamResolver:
for component in self._components
if not isinstance(component, DynamicComponent)
)
# State ownership and pressure-flow stream sensitivity are independent
# classifications. Compile this hook by behavior so algebraic
# components such as PNL00R receive their upstream-temperature
# references without dispatching a no-op to every component at runtime.
self._flow_temperature_reference_components = tuple(
component
for component in self._components
if type(component).update_flow_temperature_references
is not Component.update_flow_temperature_references
)
self._ports = tuple(
(component.name, port_name, port)
for component in self._components
@@ -122,6 +132,16 @@ class StreamResolver:
)
return values
@profile_phase("simulation.refresh", minimum_mode="audit")
def refresh_flow_temperature_references(self) -> None:
"""Refresh pressure-flow property inputs without changing stream outflows."""
connected = self.connected_temperature_reference_enthalpies()
for component in self._flow_temperature_reference_components:
component.update_flow_temperature_references(
connected[component.name]
)
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(self) -> None:
for component in self._dynamic_components:
+168 -11
View File
@@ -1,11 +1,13 @@
"""Proof-gated tangent columns for the three-piston reference network.
"""Proof-gated tangent columns for supported piston branch networks.
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`.
types, physical connections, and causal execution plan used by every selected
piston branch. The legacy three-piston entry point remains available for its
committed fixture, while the topology-driven entry point discovers any number
of branches without depending on component names. 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
@@ -57,6 +59,7 @@ class ThreePistonBranch:
chamber: object
pipe: object
contact: object
chamber_connection_port: str
velocity_index: int
position_index: int
@@ -91,7 +94,7 @@ def _failed(reason: str) -> ThreePistonTangentCompilation:
class ThreePistonTangentProvider:
"""Batched six-direction provider compiled for one system instance."""
"""Batched selected-branch provider compiled for one system instance."""
def __init__(
self,
@@ -611,10 +614,17 @@ class ThreePistonTangentProvider:
return out
def compile_three_piston_tangent_provider(
@dataclass(frozen=True)
class _PistonBranchSpec:
names: tuple[str, str, str, str, str]
chamber_connection_port: str
def _compile_named_piston_tangent_provider(
system: "GenericFluidSystem",
branch_specs: Sequence[_PistonBranchSpec],
) -> ThreePistonTangentCompilation:
"""Compile the proof-gated target provider, or return a stable reason."""
"""Compile a named, topology-proven set of supported piston branches."""
solver = system.pressure_flow_solver
if not solver.causal_fast_path_eligible:
@@ -651,7 +661,8 @@ def compile_three_piston_tangent_provider(
"amesim_lstp00a",
)
branches: list[ThreePistonBranch] = []
for names in _TARGET_BRANCH_NAMES:
for branch_spec in branch_specs:
names = branch_spec.names
try:
components = tuple(system.network.components[name] for name in names)
except KeyError:
@@ -671,6 +682,9 @@ def compile_three_piston_tangent_provider(
chamber=chamber,
pipe=pipe,
contact=contact,
chamber_connection_port=(
branch_spec.chamber_connection_port
),
velocity_index=offset,
position_index=offset + 1,
)
@@ -681,7 +695,15 @@ def compile_three_piston_tangent_provider(
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.piston.name, "port_1"),
Endpoint(
branch.chamber.name,
branch.chamber_connection_port,
),
)
),
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"))),
}
@@ -790,7 +812,7 @@ def compile_three_piston_tangent_provider(
# 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.
# relevant condition for this proof-gated branch program.
neighbor_by_endpoint: dict[Endpoint, Endpoint] = {}
for connection in system.network.connections:
if connection.kind != "physical":
@@ -848,3 +870,138 @@ def compile_three_piston_tangent_provider(
provider,
reached_assignment_count=len(reached_assignments),
)
def _physical_neighbor_map(
system: "GenericFluidSystem",
) -> dict[Endpoint, Endpoint]:
"""Return the one-to-one physical connector map proved by the network."""
neighbors: dict[Endpoint, Endpoint] = {}
for connection in system.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
# SimulationNetwork already rejects multiply connected physical ports.
# Retain a defensive gate because this compiler may also be called by
# custom network builders in tests or downstream applications.
if first in neighbors or second in neighbors:
raise ValueError("Physical endpoint has more than one connection.")
neighbors[first] = second
neighbors[second] = first
return neighbors
def _discover_supported_piston_branch_specs(
system: "GenericFluidSystem",
) -> tuple[_PistonBranchSpec, ...] | ThreePistonTangentCompilation:
"""Discover every complete catalog piston branch by type and port topology.
A PNRP17 is the unambiguous root: its mechanical piston-side port must be
driven by a singleton MECMAS21 coordinate, its pneumatic port must feed a
PNCH012 whose first port feeds PNL0001, and its rod-side port must meet an
LSTP00A contact. If even one PNRP17 is only partially supported, reject the
batch with a stable reason instead of silently omitting derivative columns.
"""
try:
neighbors = _physical_neighbor_map(system)
except ValueError:
return _failed("unsupportedPistonBranchTopology:multipleConnection")
components = system.network.components
def model_type(endpoint: Endpoint | None) -> str | None:
if endpoint is None:
return None
return getattr(components[endpoint.component], "MODEL_TYPE", None)
pistons = tuple(
component
for component in components.values()
if getattr(component, "MODEL_TYPE", None) == "amesim_pnrp17"
)
if not pistons:
return _failed("supportedPistonBranchMissing")
specs: list[_PistonBranchSpec] = []
for piston in pistons:
mass_endpoint = neighbors.get(Endpoint(piston.name, "port_2"))
if (
model_type(mass_endpoint) != "amesim_mecmas21"
or mass_endpoint is None
or mass_endpoint.port != "port_1"
):
return _failed("unsupportedPistonBranchTopology:mass")
chamber_endpoint = neighbors.get(Endpoint(piston.name, "port_1"))
if model_type(chamber_endpoint) != "amesim_pnch012":
return _failed("unsupportedPistonBranchTopology:chamber")
assert chamber_endpoint is not None
pipe_endpoint = neighbors.get(
Endpoint(chamber_endpoint.component, "port_1")
)
if (
model_type(pipe_endpoint) != "amesim_pnl0001"
or pipe_endpoint is None
or pipe_endpoint.port != "port_1"
):
return _failed("unsupportedPistonBranchTopology:pipe")
contact_endpoint = neighbors.get(Endpoint(piston.name, "port_5"))
if (
model_type(contact_endpoint) != "amesim_lstp00a"
or contact_endpoint is None
or contact_endpoint.port != "port_1"
):
return _failed("unsupportedPistonBranchTopology:contact")
specs.append(
_PistonBranchSpec(
names=(
mass_endpoint.component,
piston.name,
chamber_endpoint.component,
pipe_endpoint.component,
contact_endpoint.component,
),
chamber_connection_port=chamber_endpoint.port,
)
)
# Port uniqueness already proves unique pistons and masses, but explicitly
# reject a custom multi-port chamber/contact/pipe shared by two roots. The
# tangent propagation assumes one geometry seed per selected state owner.
for role_index in range(5):
if len({spec.names[role_index] for spec in specs}) != len(specs):
return _failed("unsupportedPistonBranchTopology:sharedComponent")
return tuple(specs)
def compile_supported_piston_tangent_provider(
system: "GenericFluidSystem",
) -> ThreePistonTangentCompilation:
"""Compile all name-independent, topology-supported piston branches."""
discovered = _discover_supported_piston_branch_specs(system)
if isinstance(discovered, ThreePistonTangentCompilation):
return discovered
return _compile_named_piston_tangent_provider(system, discovered)
def compile_three_piston_tangent_provider(
system: "GenericFluidSystem",
) -> ThreePistonTangentCompilation:
"""Compile the committed legacy three-piston target by its stable names."""
return _compile_named_piston_tangent_provider(
system,
tuple(
_PistonBranchSpec(
names=names,
chamber_connection_port="port_3",
)
for names in _TARGET_BRANCH_NAMES
),
)
+567
View File
@@ -0,0 +1,567 @@
from __future__ import annotations
from collections.abc import Callable, Sequence
from copy import copy
from dataclasses import dataclass, replace
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.ports import PortState
_STREAM_CACHE_ATTRIBUTE_NAMES = frozenset(
{
"_connected_h",
"temperature_reference_h",
}
)
def _is_stream_cache_attribute(name: str) -> bool:
"""Return whether an attribute belongs to the stream/temperature replay state.
Catalog components currently use ``_connected_h`` and
``temperature_reference_h``. The name-based extension keeps conservative
third-party caches recoverable without copying an entire component graph.
Components with opaque cache names can provide the explicit hooks documented
by :class:`ThermofluidTransactionPlan`.
"""
lowered = name.lower()
return (
name in _STREAM_CACHE_ATTRIBUTE_NAMES
or lowered.startswith("_stream_")
or "connected_h" in lowered
or "connected_enthalpy" in lowered
or "temperature_reference" in lowered
)
def _copy_cache_value(value: object) -> object:
"""Shallow-copy a stream cache without traversing the component graph."""
if isinstance(value, (dict, list, set, bytearray)):
return copy(value)
return value
@dataclass(frozen=True)
class ThermofluidWorstPort:
component: str
port: str
value: float
signed_delta: float
def as_dict(self) -> dict[str, object]:
return {
"component": self.component,
"port": self.port,
"value": self.value,
"signedDelta": self.signed_delta,
}
@dataclass(frozen=True)
class ThermofluidIterationDelta:
iteration: int
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
def as_dict(self) -> dict[str, object]:
return {
"iteration": self.iteration,
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
}
@dataclass(frozen=True)
class ThermofluidClosureSuccess:
rhs_time: float
iterations: int
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
@classmethod
def from_iteration(
cls,
rhs_time: float,
delta: ThermofluidIterationDelta,
) -> ThermofluidClosureSuccess:
return cls(
rhs_time=float(rhs_time),
iterations=delta.iteration,
max_delta=delta.max_delta,
scale=delta.scale,
tolerance=delta.tolerance,
worst_port=delta.worst_port,
)
def as_dict(self) -> dict[str, object]:
return {
"rhsTime": self.rhs_time,
"iterations": self.iterations,
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
}
@dataclass(frozen=True)
class ThermofluidClosureFailure:
failed_rhs_time: float
iterations: int
delta_tail: tuple[ThermofluidIterationDelta, ...]
max_delta: float
scale: float
tolerance: float
worst_port: ThermofluidWorstPort | None
failure_count: int = 0
@classmethod
def from_iterations(
cls,
failed_rhs_time: float,
deltas: Sequence[ThermofluidIterationDelta],
*,
tail_limit: int = 8,
) -> ThermofluidClosureFailure:
if not deltas:
raise ValueError("A thermofluid failure requires iteration diagnostics.")
final = deltas[-1]
return cls(
failed_rhs_time=float(failed_rhs_time),
iterations=final.iteration,
delta_tail=tuple(deltas[-tail_limit:]),
max_delta=final.max_delta,
scale=final.scale,
tolerance=final.tolerance,
worst_port=final.worst_port,
)
def as_dict(self) -> dict[str, object]:
return {
"failedRhsTime": self.failed_rhs_time,
"iterations": self.iterations,
"deltaTail": [item.as_dict() for item in self.delta_tail],
"maxDelta": self.max_delta,
"scale": self.scale,
"tolerance": self.tolerance,
"worstPort": (
self.worst_port.as_dict()
if self.worst_port is not None
else None
),
"failureCount": self.failure_count,
}
class ThermofluidClosureError(RecoverableTrialStateError):
"""Recoverable exhaustion of the stream/pressure-flow fixed point.
Stream propagation failures and algebraic-solver failures intentionally
retain their original exception types: rollback is still applied, but a
smaller ODE step is not known to repair those structural/numerical errors.
"""
def __init__(self, diagnostics: ThermofluidClosureFailure) -> None:
super().__init__(
"Stream enthalpy and pressure-flow coupling did not converge "
f"after {diagnostics.iterations} iterations at "
f"t={diagnostics.failed_rhs_time:.17g}."
)
self.diagnostics = diagnostics
class ThermofluidClosureDiagnostics:
"""Run-level RHS outcomes; maintenance/postprocessing calls do not write it."""
def __init__(self) -> None:
self.failure_count = 0
self.last_failure: ThermofluidClosureFailure | None = None
self.last_success: ThermofluidClosureSuccess | None = None
def record_success(self, success: ThermofluidClosureSuccess) -> None:
self.last_success = success
def record_failure(
self,
failure: ThermofluidClosureFailure,
) -> ThermofluidClosureFailure:
self.failure_count += 1
recorded = replace(failure, failure_count=self.failure_count)
self.last_failure = recorded
return recorded
def as_dict(self) -> dict[str, object]:
return {
"failureCount": self.failure_count,
"lastFailure": (
self.last_failure.as_dict()
if self.last_failure is not None
else None
),
"lastSuccess": (
self.last_success.as_dict()
if self.last_success is not None
else None
),
}
@dataclass(frozen=True)
class _PortValueBinding:
component_name: str
port_name: str
state: PortState
variable: str
@dataclass(frozen=True)
class _PortFieldPlan:
variable: str
states: tuple[PortState, ...]
@dataclass(frozen=True)
class _FlowBinding:
component_name: str
port_name: str
state: PortState
@dataclass(frozen=True)
class _ComponentCacheBinding:
component: object
attribute_names: tuple[str, ...]
attribute_name_set: frozenset[str]
snapshot_hook: Callable[[], object] | None
restore_hook: Callable[[object], None] | None
@dataclass
class ThermofluidTransactionSnapshot:
plan: ThermofluidTransactionPlan
port_values: tuple[list[float], ...]
component_cache_values: tuple[list[object], ...]
custom_cache_values: list[object | None]
diagnostic_values: list[object]
def restore(self) -> None:
plan = self.plan
plan._restore_port_values(self.port_values)
for binding, values, custom_value in zip(
plan.component_cache_bindings,
self.component_cache_values,
self.custom_cache_values,
):
component = binding.component
for name in tuple(getattr(component, "__dict__", {})):
if (
name.startswith("_causal_")
or _is_stream_cache_attribute(name)
) and name not in binding.attribute_name_set:
delattr(component, name)
for name, value in zip(binding.attribute_names, values):
setattr(component, name, _copy_cache_value(value))
if binding.restore_hook is not None:
binding.restore_hook(custom_value)
for owner, value in zip(
plan.diagnostic_owners,
self.diagnostic_values,
):
owner.last_diagnostics = value
class ThermofluidTransactionPlan:
"""Compiled, lightweight rollback boundary for one Generic RHS closure.
It snapshots active physical-port values, catalog stream-temperature caches,
component ``_causal_*`` seed fields, and resolver/solver last diagnostics.
A custom stream-aware component with an opaque mutable cache can implement
both ``snapshot_thermofluid_closure_cache()`` and
``restore_thermofluid_closure_cache(snapshot)``; these hooks are invoked in
addition to the standard name-based cache capture.
"""
def __init__(
self,
*,
port_value_bindings: tuple[_PortValueBinding, ...],
port_field_plans: tuple[_PortFieldPlan, ...],
flow_bindings: tuple[_FlowBinding, ...],
component_cache_bindings: tuple[_ComponentCacheBinding, ...],
component_count: int,
diagnostic_owners: tuple[object, ...],
) -> None:
self.port_value_bindings = port_value_bindings
self.port_field_plans = port_field_plans
self.flow_bindings = flow_bindings
self.component_cache_bindings = component_cache_bindings
self.component_count = component_count
self.diagnostic_owners = diagnostic_owners
self._snapshot = ThermofluidTransactionSnapshot(
plan=self,
port_values=tuple(
[0.0] * len(field.states)
for field in port_field_plans
),
component_cache_values=tuple(
[None] * len(binding.attribute_names)
for binding in component_cache_bindings
),
custom_cache_values=[None] * len(component_cache_bindings),
diagnostic_values=[None] * len(diagnostic_owners),
)
@classmethod
def compile(
cls,
network: object,
*,
diagnostic_owners: Sequence[object] = (),
) -> ThermofluidTransactionPlan:
components = tuple(getattr(network, "components").values())
port_value_bindings: list[_PortValueBinding] = []
port_states_by_variable: dict[str, list[PortState]] = {}
flow_bindings: list[_FlowBinding] = []
component_cache_bindings: list[_ComponentCacheBinding] = []
for component in components:
active_definitions = tuple(
definition
for definition in component.active_port_definitions
if definition.kind == "physical"
)
for definition in active_definitions:
state = component.get_port(definition.name)
flow_bindings.append(
_FlowBinding(component.name, definition.name, state)
)
for variable in definition.variables:
port_states_by_variable.setdefault(variable.name, []).append(state)
port_value_bindings.append(
_PortValueBinding(
component.name,
definition.name,
state,
variable.name,
)
)
attribute_names = tuple(
name
for name in getattr(component, "__dict__", {})
if name.startswith("_causal_")
or _is_stream_cache_attribute(name)
)
snapshot_hook = getattr(
component,
"snapshot_thermofluid_closure_cache",
None,
)
restore_hook = getattr(
component,
"restore_thermofluid_closure_cache",
None,
)
hooks_are_available = callable(snapshot_hook) and callable(restore_hook)
if attribute_names or hooks_are_available:
component_cache_bindings.append(
_ComponentCacheBinding(
component=component,
attribute_names=attribute_names,
attribute_name_set=frozenset(attribute_names),
snapshot_hook=(snapshot_hook if hooks_are_available else None),
restore_hook=(restore_hook if hooks_are_available else None),
)
)
owners = tuple(
dict.fromkeys(
owner
for owner in diagnostic_owners
if hasattr(owner, "last_diagnostics")
)
)
return cls(
port_value_bindings=tuple(port_value_bindings),
port_field_plans=tuple(
_PortFieldPlan(variable, tuple(states))
for variable, states in port_states_by_variable.items()
),
flow_bindings=tuple(flow_bindings),
component_cache_bindings=tuple(component_cache_bindings),
component_count=len(components),
diagnostic_owners=owners,
)
def capture(self) -> ThermofluidTransactionSnapshot:
# GenericFluidSystem executes one RHS serially. Reuse one compiled
# workspace rather than allocating a snapshot object and several outer
# tuples at every successful trial point.
snapshot = self._snapshot
self._capture_port_values(snapshot.port_values)
for binding, values in zip(
self.component_cache_bindings,
snapshot.component_cache_values,
):
for position, name in enumerate(binding.attribute_names):
values[position] = _copy_cache_value(
getattr(binding.component, name)
)
for position, binding in enumerate(self.component_cache_bindings):
snapshot.custom_cache_values[position] = (
binding.snapshot_hook()
if binding.snapshot_hook is not None
else None
)
for position, owner in enumerate(self.diagnostic_owners):
snapshot.diagnostic_values[position] = owner.last_diagnostics
return snapshot
def _capture_port_values(
self,
workspaces: tuple[list[float], ...],
) -> None:
for field, values in zip(self.port_field_plans, workspaces):
variable = field.variable
states = field.states
if variable == "p":
for position, state in enumerate(states):
values[position] = state.p
elif variable == "m_flow":
for position, state in enumerate(states):
values[position] = state.m_flow
elif variable == "h_outflow":
for position, state in enumerate(states):
values[position] = state.h_outflow
elif variable == "volume":
for position, state in enumerate(states):
values[position] = state.volume
elif variable == "volume_flow":
for position, state in enumerate(states):
values[position] = state.volume_flow
elif variable == "x":
for position, state in enumerate(states):
values[position] = state.x
elif variable == "v":
for position, state in enumerate(states):
values[position] = state.v
elif variable == "f":
for position, state in enumerate(states):
values[position] = state.f
else:
for position, state in enumerate(states):
values[position] = getattr(state, variable)
def _restore_port_values(
self,
workspaces: tuple[list[float], ...],
) -> None:
for field, values in zip(self.port_field_plans, workspaces):
variable = field.variable
states = field.states
if variable == "p":
for state, value in zip(states, values):
state.p = value
elif variable == "m_flow":
for state, value in zip(states, values):
state.m_flow = value
elif variable == "h_outflow":
for state, value in zip(states, values):
state.h_outflow = value
elif variable == "volume":
for state, value in zip(states, values):
state.volume = value
elif variable == "volume_flow":
for state, value in zip(states, values):
state.volume_flow = value
elif variable == "x":
for state, value in zip(states, values):
state.x = value
elif variable == "v":
for state, value in zip(states, values):
state.v = value
elif variable == "f":
for state, value in zip(states, values):
state.f = value
else:
for state, value in zip(states, values):
setattr(state, variable, value)
def flow_values(self) -> tuple[float, ...]:
return tuple(float(binding.state.m_flow) for binding in self.flow_bindings)
def measure_flow_delta(
self,
previous: Sequence[float],
*,
iteration: int,
relative_tolerance: float,
) -> ThermofluidIterationDelta:
current = self.flow_values()
scale = max(
(abs(value) for value in (*previous, *current)),
default=1.0,
)
scale = max(scale, 1.0)
worst_index = -1
worst_signed_delta = 0.0
max_delta = 0.0
for index, (old, new) in enumerate(zip(previous, current)):
signed_delta = new - old
magnitude = abs(signed_delta)
if magnitude > max_delta:
worst_index = index
worst_signed_delta = signed_delta
max_delta = magnitude
worst_port = None
if worst_index >= 0:
binding = self.flow_bindings[worst_index]
worst_port = ThermofluidWorstPort(
component=binding.component_name,
port=binding.port_name,
value=current[worst_index],
signed_delta=worst_signed_delta,
)
return ThermofluidIterationDelta(
iteration=int(iteration),
max_delta=max_delta,
scale=scale,
tolerance=float(relative_tolerance) * scale,
worst_port=worst_port,
)
def diagnostics(self) -> dict[str, int]:
stream_cache_slot_count = sum(
len(binding.attribute_names)
for binding in self.component_cache_bindings
)
return {
"physicalPortValueSlotCount": len(self.port_value_bindings),
"physicalFlowPortCount": len(self.flow_bindings),
"componentCount": self.component_count,
"cacheBindingCount": len(self.component_cache_bindings),
"streamAndCausalCacheSlotCount": stream_cache_slot_count,
"customCacheHookCount": sum(
binding.snapshot_hook is not None
for binding in self.component_cache_bindings
),
"diagnosticOwnerCount": len(self.diagnostic_owners),
}
+87 -30
View File
@@ -37,7 +37,14 @@ from app.simulation.solvers.stream import StreamResolver
from app.simulation.solvers.tangent import (
ThreePistonTangentCompilation,
ThreePistonTangentProvider,
compile_three_piston_tangent_provider,
compile_supported_piston_tangent_provider,
)
from app.simulation.solvers.thermofluid import (
ThermofluidClosureDiagnostics,
ThermofluidClosureError,
ThermofluidClosureFailure,
ThermofluidClosureSuccess,
ThermofluidTransactionPlan,
)
from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -114,10 +121,6 @@ class SimulationPreparationError(ValueError):
self.issues = issues
class ThermofluidClosureError(RuntimeError):
"""Raised when stream enthalpy and pressure-flow do not reach one fixed point."""
class SimulationSampleTimeError(ValueError):
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
@@ -428,6 +431,17 @@ class GenericFluidSystem:
self.signal_resolver = SignalResolver(network)
self.stream_resolver = StreamResolver(network)
self._thermofluid_closure_plan = self._build_thermofluid_closure_plan()
self._thermofluid_transaction_plan = ThermofluidTransactionPlan.compile(
network,
diagnostic_owners=(
self.signal_resolver,
self.pneumatic_volume_resolver,
self.stream_resolver,
self.pressure_flow_solver,
*self._thermofluid_closure_plan.secondary_pressure_solvers,
),
)
self._thermofluid_closure_diagnostics = ThermofluidClosureDiagnostics()
self.algebraic_solve_count = 0
self.algebraic_seeded_solve_count = 0
self.algebraic_nonlinear_solve_count = 0
@@ -945,7 +959,45 @@ class GenericFluidSystem:
minimum_mode="audit",
reset_property_shadow=True,
)
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
def _close_current_state(
self,
time: float,
*,
record_rhs_outcome: bool = False,
) -> dict[str, dict[str, float]]:
transaction = self._thermofluid_transaction_plan.capture()
last_algebraic_diagnostics = self._last_algebraic_diagnostics
last_algebraic_scope = self._last_algebraic_scope
try:
connected_h, success = self._close_current_state_unchecked(time)
except ThermofluidClosureError as exc:
transaction.restore()
self._last_algebraic_diagnostics = last_algebraic_diagnostics
self._last_algebraic_scope = last_algebraic_scope
self._request_causal_residual_audit()
if record_rhs_outcome:
failure = self._thermofluid_closure_diagnostics.record_failure(
exc.diagnostics
)
raise ThermofluidClosureError(failure) from None
raise
except BaseException:
transaction.restore()
self._last_algebraic_diagnostics = last_algebraic_diagnostics
self._last_algebraic_scope = last_algebraic_scope
self._request_causal_residual_audit()
raise
if record_rhs_outcome:
self._thermofluid_closure_diagnostics.record_success(success)
return connected_h
def _close_current_state_unchecked(
self,
time: float,
) -> tuple[
dict[str, dict[str, float]],
ThermofluidClosureSuccess,
]:
signal = self.signal_resolver.solve(time)
self.signal_propagation_count += signal.propagated
self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
@@ -971,27 +1023,22 @@ class GenericFluidSystem:
closure_plan = self._thermofluid_closure_plan
self._last_algebraic_diagnostics = initial_algebraic
self._last_algebraic_scope = closure_plan.global_component_group
physical_ports = closure_plan.physical_ports
secondary_pressure_solvers = closure_plan.secondary_pressure_solvers
secondary_block_solvers = closure_plan.secondary_block_solvers
connected_h: dict[str, dict[str, float]] = {}
stream_diagnostics = []
coupling_deltas = []
max_coupling_iterations = 25
flow_relative_tolerance = 1.0e-12
for coupling_iteration in range(1, max_coupling_iterations + 1):
previous_flows = tuple(port.m_flow for port in physical_ports)
previous_flows = self._thermofluid_transaction_plan.flow_values()
stream, connected_h = self.stream_resolver.solve(
dynamic_ports_are_current=True,
)
stream_diagnostics.append(stream)
temperature_reference_h = (
self.stream_resolver.connected_temperature_reference_enthalpies()
)
for component in self.dynamic_components:
component.update_stream_outflows(connected_h[component.name])
component.update_flow_temperature_references(
temperature_reference_h[component.name]
)
self.stream_resolver.refresh_flow_temperature_references()
if secondary_pressure_solvers:
self.thermofluid_pressure_pass_count += 1
block_scale_context = (
@@ -1043,26 +1090,23 @@ class GenericFluidSystem:
self._last_algebraic_diagnostics = algebraic
self._last_algebraic_scope = component_group
pressure_flow_solve_count += 1
current_flows = tuple(port.m_flow for port in physical_ports)
flow_scale = max(
[abs(value) for value in (*previous_flows, *current_flows)] + [1.0]
)
max_flow_delta = max(
(
abs(current - previous)
for previous, current in zip(previous_flows, current_flows)
),
default=0.0,
coupling_delta = self._thermofluid_transaction_plan.measure_flow_delta(
previous_flows,
iteration=coupling_iteration,
relative_tolerance=flow_relative_tolerance,
)
coupling_deltas.append(coupling_delta)
if (
not secondary_pressure_solvers
or max_flow_delta <= flow_relative_tolerance * flow_scale
or coupling_delta.max_delta <= coupling_delta.tolerance
):
break
else:
raise ThermofluidClosureError(
"Stream enthalpy and pressure-flow coupling did not converge "
f"after {max_coupling_iterations} iterations."
ThermofluidClosureFailure.from_iterations(
time,
coupling_deltas,
)
)
self.max_thermofluid_iterations = max(
self.max_thermofluid_iterations,
@@ -1105,7 +1149,10 @@ class GenericFluidSystem:
self.max_stream_iterations,
*(item.iterations for item in stream_diagnostics),
)
return connected_h
return connected_h, ThermofluidClosureSuccess.from_iteration(
time,
coupling_deltas[-1],
)
@profile_phase("simulation.refresh", minimum_mode="audit")
def _refresh_dynamic_components(self) -> None:
@@ -1130,7 +1177,10 @@ class GenericFluidSystem:
@profile_phase("simulation.rhs", minimum_mode="audit")
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
self.apply_state_vector(state_vector)
connected_h = self._close_current_state(_time)
connected_h = self._close_current_state(
_time,
record_rhs_outcome=True,
)
derivatives = self._state_derivatives(connected_h)
provider = self._ode_tangent_provider
if provider is not None:
@@ -1253,7 +1303,7 @@ class GenericFluidSystem:
exact_columns = None
if requested_jacobian_mode == "semi-analytic":
tangent_compilation = (
compile_three_piston_tangent_provider(self)
compile_supported_piston_tangent_provider(self)
)
if tangent_compilation.eligible:
provider = tangent_compilation.provider
@@ -1329,6 +1379,7 @@ class GenericFluidSystem:
),
jac_sparsity=jac_sparsity,
jac=jacobian,
recoverable_trial_retries=True,
)
finally:
self._ode_tangent_provider = None
@@ -1650,6 +1701,12 @@ class GenericFluidSystem:
"stream": {
"maxIterationsPerSolve": self.max_stream_iterations,
"maxThermofluidIterations": self.max_thermofluid_iterations,
"thermofluidClosure": {
**self._thermofluid_closure_diagnostics.as_dict(),
"transaction": (
self._thermofluid_transaction_plan.diagnostics()
),
},
"last": (
self.stream_resolver.last_diagnostics.as_dict()
if self.stream_resolver.last_diagnostics is not None
+12
View File
@@ -0,0 +1,12 @@
# Reference direct-dependency set for CPython 3.12.
#
# This intentionally pins only packages imported or invoked directly by the
# application. In particular, it does not pin uvicorn[standard]'s optional,
# platform-dependent transitive dependencies. Regenerate and validate these
# pins in a clean CPython 3.12 environment when intentionally upgrading them.
fastapi==0.141.1
lxml==6.1.1
numpy==2.5.2
pydantic==2.13.4
scipy==1.18.0
uvicorn==0.52.3
+190 -55
View File
@@ -5,6 +5,9 @@
> 基线代码:`6bb0591d320d0c448ee8d224dd44127bfe3ce00f`(本地 `model-development`)
> 基线模型:`tests/data/test_mql-full-branches-01-04.xml`
> 模型 SHA-256:`2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48`
> 当前主固化目标:`tests/data/test-mql-8.xml`
> 主目标 XML SHA-256:`170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3`
> 配套项目 JSON:`tests/data/test-mql-8.json`,SHA-256 `258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa`
## 1. 使用规则
@@ -34,7 +37,7 @@
### 2.2 环境说明
仓库内 `.venv` 当前不完整,本次复测使用现有 `/opt/srm-trial-review/.venv`:
首次历史复测时仓库 `.venv` 尚不完整,因此当时使用现有 `/opt/srm-trial-review/.venv`:
| 项目 | 本次值 |
| --- | --- |
@@ -92,16 +95,34 @@
| stream 块 / stream 未知量 | 9 / 192 |
| 结果变量 | 1,021 |
### 2.5 新主固化目标 `test-mql-8`
自 2026-08-17 起,后续通用求解器优化以 `tests/data/test-mql-8.xml` 为主固化目标;配套 `test-mql-8.json` 用于校验项目结构,但 XML 是权威执行输入。原 `test_mql-full-branches-01-04.xml` 继续保留为历史慢区、2.05 s 与首批半解析 Jacobian 的回归样例。runner 只在内存中覆盖 `tStop/sampleStep/maxStep`,不得改写权威输入。
| 项目 | 主目标值 |
| --- | ---: |
| 运行组件 / 连接 | 152 / 174 |
| 动态组件 / 连续状态 | 54 / 124 |
| 代数未知量 / 方程 | 760 / 760 |
| ODE Jacobian 结构 | 3296 nnz / 52 色 |
| 因果 effort / flow 赋值 | 432 / 328 |
| secondary 代数块 / 未知量 | 12 / 352 |
| 结果变量 | 1,716 |
| 原始 `tStop / sampleStep / maxStep` | 0.2 / 0.01 / 0.01 s |
| 信号断点 | 0.04、0.8 s |
本轮参考环境使用仓库 `.venv`:Python 3.12.3、NumPy 2.5.2、SciPy 1.18.0。`.python-version` 与 `constraints/python312-direct.txt` 已固定 Python 和六个直接依赖,README、CI 与依赖契约测试使用同一安装口径;它有意不锁平台相关传递依赖与 wheel 哈希,因此是可审计的参考约束,不是发布级逐位锁。机器可读 manifest 与 runner 分别位于 `tests/baselines/simulation/test_mql_8/manifest.json` 和 `app/simulation/benchmark_regression.py`;默认顺序为 `0.01 smoke → 0.2 → 1 → 5 → 10 s`,每档均有合作取消、硬终止、资源记录与后续档延迟门,且 `sampleStep` 与 `maxStep` 可按 lane 独立覆盖。
## 3. 总体验收协议
每个优化 PR 至少执行以下分层验证;高风险改动不得只用单点输出或单个哈希判断正确性。
### 3.1 快速结构检查(CI)
- [ ] 模型输入 SHA-256 与固定 fixture 一致。
- [ ] 组件、连接、状态、代数方程和 stream 结构数量符合预期。
- [ ] Jacobian 结构至少覆盖已知跨域依赖,并通过稠密数值扰动抽查。
- [ ] 因果计划覆盖率、回退原因和审计失败数可观测。
- [x] 模型输入 SHA-256 与固定 fixture 一致。
- [x] 组件、连接、状态、代数方程和 stream 结构数量符合预期。
- [x] Jacobian 结构至少覆盖已知跨域依赖,并通过稠密数值扰动抽查。
- [x] 因果计划覆盖率、回退原因和审计失败数可观测。
### 3.2 数值检查点
@@ -110,7 +131,7 @@
- [ ] `0.68–0.71 s`:历史慢区。
- [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。
- [ ] `2.00–2.10 s`:此前报告卡死区间和状态切换。
- [ ] `10 s`:长时间模式变化验证,完成 OPT-09 后启用。
- [x] `10 s`:最终通用接线后的当前工作树已完成首次长时间模式变化运行;连续 3 次和批准 golden 仍属于 OPT-09 后续。
每个检查点比较:连续状态、关键压力/流量/位移/速度、事件时刻与顺序、模式状态、有限性、最大缩放残差及守恒量。
@@ -131,16 +152,16 @@
| ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 |
| --- | --- | --- | --- | --- | --- | --- |
| OPT-00 | P0 | 固化复现、环境和回归基线 | 进行中 | 中 | 很高 | 无 |
| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 部分实现 | 中高 | 中高 | OPT-00 |
| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 未开始 | 很高 | 很高 | OPT-01 |
| OPT-00 | P0 | 固化复现、环境和回归基线 | 部分实现(基础闭环) | 中 | 很高 | 无 |
| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 基本完成(主要矛盾闭环) | 中高 | 中高 | OPT-00 |
| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 部分实现(参考 IR) | 很高 | 很高 | OPT-01 |
| OPT-03 | P1 | 稀疏 Jacobian 数值层与解析/半解析演进 | 部分实现 | 很高 | 很高 | OPT-00;解析链可与 OPT-02 分阶段 |
| OPT-04 | P1 | stream 拓扑传播与物性成组复用 | 部分实现 | 中高 | 中高 | OPT-00 |
| OPT-05 | P1 | 状态缩放、分量容差和步长策略 | 未开始 | 中高 | 中高 | 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-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 可先做最小原型,再根据端到端数据调整顺序。
@@ -151,82 +172,127 @@
**目标**:先让“是否更快、是否仍正确、是否又卡住”可以稳定复现和自动判断。
**当前状态**:已有手工 `0.81 s` 和 `2.10 s` 复测及若干结构回归;复杂 XML、正式运行环境、分层性能门槛尚未完整固化。
**当前状态**:P0 基础设施与新主目标的有界 `0.2 s` 基线已经闭环:权威 XML/JSON、参考依赖约束、仓库内 runner、状态 golden、输出形状契约、三层 CI 和机器可读报告均已建立。完整 OPT-00 仍缺发布级依赖锁、远端 CI 首次实跑、历史 `2.10 s` 三次复测以及更长时域的批准 golden。
**工作项**:
- [ ] 将复杂 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 基准结果,避免只在文档中抄写数字。
- [x] 将新主目标 XML/JSON 放入固定 fixture 路径,并在 manifest/测试中校验双哈希、字节数和配对配置;提交本轮工作时必须一并纳入版本控制。
- [x] 建立 Python 3.12.3 与六个直接依赖的跨平台参考约束,并在 CI 中校验;发布级传递依赖/wheel 哈希锁仍待后续。
- [x] 将临时探针整理为仓库内可重复运行的 benchmark,不依赖 `/tmp` 文件。
- [x] 添加 `0.81 s` 和仅改 `tStop=2.10 s` 的历史标准运行入口。
- [x] 添加模型结构快照断言;结构有意变化时显式更新原因。
- [x] 建立 `physical-state-v2` 的首批 state/checkpoint/event 投影,并批准 production `0.2 s` golden。
- [ ] 将关键压力、流量、守恒量和离散模式加入 `physical-state-v2.1` 数值投影。
- [x] 将无数值的完整输出形状契约与物理状态 golden 分开;完整 API 序列化契约若需逐字段稳定性,后续另行定义。
- [x] 建立短 CI、夜间 `0.81/2.10 s`、定期递进至 `10 s` 的三层 workflow;远端首次执行待提交后确认。
- [x] 保存带环境、仓库、输入、运行统计和验收结果的机器可读 JSON 报告。
**验收条件**:
- [ ] 干净环境一条命令可复现;失败时能区分超时、无进度、数值失败和服务失败。
- [ ] 干净环境一条命令可复现;当前参考约束、`pip check`、soft/hard timeout、worker error 与 correctness failure 分类已完成,独立无进度看门狗、服务级故障分类和发布级空环境重建尚未验收。
- [ ] 正式环境连续 3 次完成 `2.10 s`,结果满足数值契约且无非预期回退。
- [ ] 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。
- [x] 新主目标 `0.2 s` 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。
**前后对比**:
| 指标 | 当前 | 完成后 |
| --- | --- | --- |
| 正式锁定环境 | 无 | 待填 |
| 复杂模型自动回归 | 部分 | 待填 |
| 物理解哈希 | 环境相关 v1 | 待填 |
| 正式锁定环境 | 无 | Python 3.12.3 + 直接依赖参考约束;发布锁待补 |
| 复杂模型自动回归 | 部分 | 新主目标 0.01/0.2/1/5/10 递进 + 历史 0.81/2.10 入口 |
| 物理解哈希 | 环境相关 v1 | production 0.2 state golden + 独立 output shape contract |
| `2.10 s` 连续成功率 | 单次证据 | 待填 |
#### 2026-08-17 / `test-mql-8` 固化 runner v2
- 状态:进行中 → 部分实现(P0 基础闭环)。新权威 XML/JSON、双哈希、结构快照、参考环境约束、分层 manifest、机器可读报告、批准的 production `0.2 s` golden 和仓库内 runner 已建立;发布级依赖锁、关键代数量投影以及该里程碑时尚未运行的 1/5/10 s 结果仍待后续。
- runner 行为:默认严格按 `0.01 smoke → 0.2 → 1 → 5 → 10 s` 递进;smoke 不参与耗时外推。soft deadline 先经 stdin 合作取消,hard deadline 再 terminate/kill;失败、超时、物理验收失败或下一档预测超过预算时,剩余档位统一标记 `deferred`。
- 已执行正确性门:完成并到达终点、非空且全有限的输出序列、采样时间严格递增、检查点及状态值、最大缩放残差、预期信号事件及其实际积分分段、机械切换次数/时刻、golden 来源报告与布局哈希、逐状态容差比较和独立 output-shape contract。
- 两条 lane:manifest 中 `solver-only` 在内存把 `sampleStep` 改为 0.02 s,并把 `maxStep` 固定为 0.05 s,用于算法迭代;`production` 的 `sampleStep/maxStep` 均使用权威 XML 源值,当前为 0.01/0.01 s。max-step 矩阵可再显式覆盖单次运行的 `maxStep`;所有覆盖都只发生在内存,不改写 XML。
- 进程鲁棒性:软取消、硬终止、子进程提前关闭 stdin 的 BrokenPipe 和 stdout/stderr 资源清理均有自动测试。
- 备份:`backup/general-solver-v1-before-20260817-16a7eb2` 精确指向进入本轮前的 `16a7eb2d6c2f01b23e3bdc7781a6cf6cc3fbe369`。
- P0 证据:`tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json`、`goldens/production-0.2s-v1.json` 与 `runs/2026-08-17-production-v2-extension-decision.json`。
- 自动验证:CI 同口径快速基础套件共 149 项,OK(2 项长时测试按开关跳过);全量后端 discover 共 792 项,OK(3 项长时/可选测试跳过)。原有 5 个失败均确认是仓库整理后的旧文档/XML/CSV 路径,并已修正为现有 fixture 路径。
递进复测命令:
```bash
PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
--manifest tests/baselines/simulation/test_mql_8/manifest.json \
--lane production \
--output tests/baselines/simulation/test_mql_8/runs/latest-production.json
```
正式验收默认使用 `production`,从而执行已批准的 0.2 s 数值 golden;算法迭代若需降低输出成本,可显式改为 `--lane solver-only`。仅重跑首个正式基线档可加 `--case 0.2s`。
命令退出码约定:`0` 表示所有选定档完成,`2` 表示依据预算安全暂缓后续档,`1` 表示运行失败或正确性验收失败。显式选择 `1s/5s/10s` 时,runner 仍会自动补齐并先执行所有较短前置档。
### OPT-01 完成因果代数内核与坐标消元
**目标**:在已存在的因果快速路径上,真正移除运行时冗余坐标和对象访问,而不是再次实现一套同类快速路径。
**当前状态**:主要思路已经实现。全局和 secondary stream 块可以执行显式因果计划,完整残差按 64 次间隔审计;`2.10 s` 中快速执行 22,216 次、审计 351 次、失败和旧路径回退均为 0。仍保留 472 个运行时未知量,204 个重复 effort 坐标尚未在执行层消除,且存在清零、复制、缩放、`getattr/setattr` 和完整对象遍历成本。
**当前状态**:新主目标的主要矛盾已经在执行层闭环。原有 `760` 个 PortState 兼容代数槽由 `432` 个 effort 槽和 `328` 个 flow/force 槽组成;当前内核将其编译为 `112` 个 effort 等价组和 `328` 条显式赋值,即 `440` 个逻辑坐标,在求解执行层消去 `320` 个 effort 别名。全局与 secondary stream 块均使用预分配 workspace、按 component 批量计算 anchor 并直接 scatter,完整残差仍在初始化、事件和每 64 次求解时审计。
一次已预热的 A/B 微基准显示,整个 RHS 的因果快速模式中位数约 `200.8 ms/100 次`,强制完整检查约 `297.4 ms/100 次`,即现有路径已经取得约 `1.48×` 的整 RHS 收益。单纯继续增大审计间隔预计收益有限。
这里的“消去”是逻辑求解坐标消元:stream、状态导数和结果提取仍直接读取 `760` 个 PortState 兼容镜像,因此对象槽尚未物理删除;这属于 OPT-02 后续。旧 `472/204/68/200` 是历史 `test_mql-full-branches-01-04.xml` 的规模,只保留为历史基线,不再描述当前主目标。
**工作项**:
- [ ] 将 68 个 effort 等价组压缩为独立运行时坐标,消除 204 个重复 effort 槽。
- [ ] 将 200 条显式 flow/force 规则预编译为稳定顺序和整数槽索引。
- [ ] 用预分配连续数组代替热路径对象读写、临时字典和重复缩放。
- [x] 将 112 个 effort 等价组压缩为独立逻辑坐标,在执行层消去 320 个重复 effort 别名。
- [x] 将 328 条显式 flow/force 规则预编译为稳定阶段和槽绑定。
- [x] 用预分配 workspace、批量 component anchor 和直接属性 scatter 减少热路径对象遍历、临时集合与重复缩放。
- [ ] 仅清理会被当前计划写入的槽,避免每次全量清零和复制。
- [ ] 保留初始化、事件后、接受步或固定间隔的完整残差审计。
- [ ] 自定义组件、声明缺失、审计失败或奇异结构必须自动回退旧求解器。
- [ ] 输出编译统计:消元数、显式规则覆盖率、审计率、失败原因和回退次数。
- [x] 保留初始化、事件后、显式请求或固定间隔的完整残差审计。
- [x] 自定义组件、声明缺失、审计失败、非有限外部 effort 或奇异结构自动回退旧求解器。
- [x] 输出逻辑/兼容坐标数、消元数、显式规则覆盖率、审计率、失败原因和回退次数。
“仅清理当前计划写入槽”暂不勾选:当前 flow 目标仍先清零再赋值,以保持既有 `target = -residual(target=0)` 语义逐位一致;在 IR 能证明目标系数与历史无关前不移除这一步。
**验收条件**:
- [ ] 复杂模型因果覆盖率大于 98%,完整 `0.81/2.10 s` 运行审计失败为 0。
- [ ] 新旧路径的状态、事件、关键代数量和残差均满足统一数值契约。
- [ ] 自定义组件、接触模型和非因果结构的回退测试全部通过。
- [ ] 在完整模型上证明端到端收益;不得只提交代数微基准。
- [x] 新主目标因果 flow/force 覆盖率为 `328/328`,0.01/0.2 s 中审计、运行时验证和旧路径回退均为 0。
- [x] kernel on/off 的状态导数、760 个兼容代数槽、积分统计、物理解与输出契约一致。
- [x] 自定义组件、接触模型、非因果结构和故障注入的回退测试通过。
- [x] 在 0.01 s 与 production 0.2 s 证明端到端不退化并取得单次收益;严格性能签收仍需补 3 次中位数。
**风险与回滚**:别名写回、事件后模式改变和不完整依赖声明可能造成静默错误。新路径必须可通过配置关闭,并在审计失败时记录首个违规方程与变量。
| 指标 | 当前 | 完成后 |
| --- | ---: | ---: |
| 运行时代数槽 | 472 | 待填 |
| 重复 effort 槽 | 204 | 待填 |
| 已预热 Python 调用/单 RHS | 约 15,774 | 待填 |
| 因果审计失败 | 0 | 待填 |
| `0.81/2.10 s` 墙钟中位数 | 63.779 / 126.211 s(单次环境值) | 待填 |
| 兼容代数槽 | 760 | 760(逻辑坐标 440) |
| 重复 effort 别名 | 320 | 逻辑消去 320;兼容镜像保留 |
| 已预热 Python 调用/单 RHS | 9,423 | 5,955(`-36.8%`) |
| 全局代数 solve 中位时间 | 0.708890 ms | 0.521711 ms(`-26.4%`) |
| 整体 RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(`-12.9%`) |
| 0.01 s worker 墙钟 | 13.5983 s | 12.5878 s(`-7.43%`) |
| production 0.2 s worker 墙钟 | 135.8240 s | 130.8233 s(单次 `-3.68%`) |
| 因果审计 / 运行时验证 / 旧路径回退失败 | 0 / 0 / 0 | 0 / 0 / 0 |
#### 2026-08-17 / 通用因果执行器 v2
- 状态:该段记录低分配执行器 v2 的首版里程碑;后续因果坐标内核已将新主目标的 `760` 个兼容槽压缩为 `440` 个逻辑坐标,OPT-01 当前已达到“基本完成(主要矛盾闭环)”。`760` 个 PortState 兼容镜像的物理删除仍属于 OPT-02 后续。
- 全局执行:直接执行预编译的 432 个 effort 写入与 328 个 flow/force 赋值,普通 fast solve 不再构造 seeded-id set、遍历 760 个未知量或重复构造 diagnostics。
- secondary 执行:对 352 未知量的因果块仅保存和写入 176 个 selected flow 槽,普通 fast solve 跳过完整 mutation snapshot、seed set 和 scale/residual 构造。
- 正确性边界:初始化、事件、显式请求及每 64 次求解仍执行完整残差审计;非有限 assignment、stage 异常或外部机械 x/v 非有限会熔断 v2,并在同次求解回到旧 seed/audit 路径。`SIMULATION_CAUSAL_EXECUTOR_V2=0` 保留一键回滚。
- 默认决策:在目标 0.01 s 逐位 A/B、故障注入、聚焦测试与完整 0.2 s 验收后,v2 设为通用默认;只在原有 causal compile 证明通过时启用,不满足证明的模型继续走原路径。
- 微基准:新目标 100 次同状态 RHS 中位时间由 0.305764 s 降至 0.247467 s(单次基准约 `-19.1%`),导数逐位相同;405 次 v2 fast、7 次完整审计,0 次验证失败。
- 0.01 s 端到端:SciPy Jacobian 下总墙钟 15.160 → 13.172 s(`-13.1%`),积分 14.092 → 12.122 s(`-14.0%`);`nfev/njev/nlu=526/48/149`、物理解哈希 `0e64c6f...` 均相同。
- 历史 0.2 s solver-only:旧空格路径、SHA `42e2d627...` 与 0.002 s 网格下曾以 132.305 s 完成;报告 `runs/2026-08-17-solver-only-v1.json` 和旧 `runs/2026-08-17-extension-decision.json` 已在 manifest 中标为 `historicalOnly`,不得作为新权威输入的 golden 或耗时预测来源。
- 当前 production 0.2 s:新 SHA `170463d6...` 与 0.01 s 网格下 worker 墙钟 135.824 s、CPU 139.105 s、峰值 RSS 189,874,176 B;`nfev/njev/nlu=5755/307/1081`,接受步 1696,2 个信号分段,0 状态切换/重试。50,615 次闭合全部 seeded,主 v2 fast/audit 为 21,771/341,审计失败、运行时验证失败和旧路径回退均为 0,最大缩放残差 `1.0947e-16`。
- 当前 P0 报告与 golden:`runs/2026-08-17-production-v2-0.2.json` 通过全部验收门;`goldens/production-0.2s-v1.json` 对 134 个投影结果键的 3 个检查点共比较 402 个值,并独立校验 output contract。本目标仍使用 SciPy Jacobian,不能把该成绩归因于半解析 Jacobian。
- 当时的延期决策:`runs/2026-08-17-production-v2-extension-decision.json` 绑定新报告 SHA;`1 s` 的 1018.680 s 由 `135.8240278 × 5 × 1.5` 保守外推,超过 900 s soft budget,因此在该里程碑先未启动 1/5/10 s。后续实测结论统一记录在 OPT-09,不用该历史外推覆盖实测。
### OPT-02 建立扁平数值 IR 和数组执行内核
**目标**:把组件对象、字典查找和端口读写转换成稳定的数值执行计划,为 NumPy、Numba 或原生后端提供共同基础。
**当前状态**:构建阶段已有一定预绑定,但 RHS 仍以 Python 对象和方法调用为主。已预热、启用物性缓存时,采样剖析约有 15,774 次 Python 调用/RHS;不同缓存上下文会明显改变该数字,所以后续必须统一测量口径。
**当前状态**:已启动第一版独立、可执行的 schema v1 参考 IR,但尚未接管默认热路径。它把结构程序与运行绑定分离,包含 `440 canonical / 760 compatibility` 双层槽、稳定结构签名、NumPy workspace、按 component 批量 effort 计算、六阶段 flow 执行、逐阶段观察器和可选事务模式。权威目标可编译为 `112` 个 effort 坐标、`328` 个 flow 坐标和 `320` 个逻辑别名消元,flow stages 为 `[110, 130, 49, 33, 5, 1]`。
该原型目前只覆盖全局因果代数计划;secondary、stream、结果提取、模式重编译、自定义适配器和原生后端均未接入。PortState 仍是兼容镜像。事务模式目前只保证受控返回失败的回滚,writer/MemoryError/BaseException 语义尚未冻结;结构签名也未包含组件实现版本和后端,因此不能作为持久缓存键。
**工作项**:
- [ ] 定义最小数值 IR:连续槽、常量、参数、状态、代数量、模式位和操作码。
- [x] 定义首批最小代数 IR:canonical/compatibility 双层槽、稳定绑定、常量和分阶段操作码。
- [ ] 将组件方程、因果规则、stream 传播和结果提取分成明确执行阶段。
- [ ] 先实现可逐项对照的纯 Python/NumPy 参考后端。
- [ ] 添加 IR 与当前对象执行器逐操作/逐阶段差分测试。
- [x] 实现可执行的纯 Python/NumPy 全局因果参考后端。
- [x] 添加 IR 与当前对象执行器的结构签名、逐槽和逐阶段差分测试。
- [ ] 评估 Numba 与 C/C++ 后端;在 IR 稳定前不绑定单一编译技术。
- [ ] 对动态自定义组件保留对象适配层和明确的性能降级提示。
- [ ] 缓存编译结果,并以模型结构、组件版本和数值后端作为缓存键。
@@ -240,11 +306,17 @@
| 指标 | 当前 | 原型后 | 完成后 |
| --- | ---: | ---: | ---: |
| Python 调用/单 RHS | 约 15,774 | 待填 | 待填 |
| Python 调用/单 RHS | 9,423 | 5,955(OPT-01 默认内核;参考 IR 尚未接线) | 待填 |
| 临时分配字节/单 RHS | 待测 | 待填 | 待填 |
| RHS 中位时间 | 约 2 ms(现有微基准口径) | 待填 | 待填 |
| RHS 中位时间 | 250.742 ms / 100 次 | 218.343 ms / 100 次(OPT-01) | 待填 |
| `2.10 s` 积分时间 | 122.180 s | 待填 | 待填 |
#### 2026-08-17 / 因果数值 IR schema v1
- 新增独立参考实现 `app/simulation/solvers/causal_ir.py`,将结构程序与运行绑定分离,覆盖 `440 canonical / 760 compatibility` 双层槽、`112` 个 effort 坐标、`328` 个 flow 坐标、`320` 个逻辑别名及六阶段 flow 计划。
- `tests/test_causal_numeric_ir.py` 已覆盖结构签名、逐槽、逐阶段、观察器和受控事务回滚差分。
- 该 IR 尚未接管默认 RHS,当前不能把 OPT-01 的调用数或墙钟收益归因于 IR;secondary、stream、结果提取、事件后模式计划和原生后端仍待接入。
### OPT-03 稀疏 Jacobian 数值层与解析/半解析演进
**目标**:先建立可审计、可回滚的 callable sparse Jacobian 数值层,再逐步把组件、因果代数计划、stream 和物性的局部导数传播进来。完整稀疏有限差分、受审计 secant 和真正的解析/半解析 Jacobian 是三个不同阶段,必须分别记录和验收。
@@ -392,11 +464,18 @@
- 代码备份:仍使用进入 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`
#### 2026-08-17 / 名字无关的受支持活塞支路编译器
- 将原三条固定实例扩展为按组件类型、端口域、连接、机械状态 owner/slot、因果 reach 与 stream 影响证明自动发现任意数量的受支持支路;通用路径不固定组件实例名、支路数或状态 offset,旧三活塞入口仅作为兼容 wrapper。
- 新主目标自动发现 8 条 MECMAS21→PNRP17→PNCH012→PNL0001/LSTP 支路,覆盖 16 个机械状态列 `104..119` 与 84 条可达赋值;理论剩余 FD 颜色由 52 降至 36。
- 平滑工作点 16 列对完整 RHS 中心差分通过;初始接触边界会类型化回退完整 52 色数值 Jacobian,不会静默使用错误列。
- 0.01 s A/B 显示该目标早期 56 次 Jacobian 中只有 16 次使用精确列、40 次因流量局部斜率/接触边界安全回退;单独半解析总墙钟为 17.522 s,慢于 SciPy 的 15.160 s。当前目标因此继续使用默认 SciPy Jacobian,半解析保持显式 opt-in,下一步应做支路分区回退或扩大光滑模式覆盖,而不是放宽守卫。
### OPT-04 stream 拓扑传播与物性成组复用
**目标**:让无环 stream 网络一次传播,只对真正的强连通块迭代;同一状态反算的物性量成组计算和复用。
**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点最多 25 次,本模型实测最多 3 次。
**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点上限仍为 25 次:production `0.2 s` 实测最多 3 次,修复后延长到 `2/5/10 s` 实测最多 18–23 次;修复前在 `t≈1.8595–1.8603 s` 会耗尽 25 次。当前已补充试探点事务回滚和类型化可恢复失败,并由 StreamResolver 为所有覆盖温度参考更新钩子的组件统一刷新连接参考;SCC/DAG 传播与物性成组复用尚未实现。
**工作项**:
@@ -407,6 +486,8 @@
- [ ] 将 `p/T/rho/h/s` 等同源物性组织为状态包,按精确输入键成组复用。
- [ ] 增加缓存命中、SCC 迭代、失效原因和物性调用次数指标。
- [ ] 评估脏标记传播,但必须证明事件和反向流切换时不会复用陈旧值。
- [x] 为热流体外层 25 次耗尽提供类型化可恢复失败和单次 RHS 事务回滚,避免失败试探点污染下一次尝试;这是鲁棒性前置,不代表 SCC/DAG 优化已经完成。
- [x] StreamResolver 按组件行为预编译所有覆盖 `update_flow_temperature_references` 的组件,并在每轮 stream 更新后统一刷新温度参考;物理岛边界同时识别 stream outflow 与温度参考钩子覆盖。
**验收条件**:
@@ -417,7 +498,7 @@
| 指标 | 当前 | 完成后 |
| --- | ---: | ---: |
| stream 块 / 未知量 | 9 / 192 | 待填 |
| 最大热流体迭代 | 3 | 待填 |
| 最大热流体迭代 | production 0.2 s:3;修复后 2/5/10 s:18–23;恢复阈值:25 | 待填 |
| `2.10 s` 压力闭合 | 57,601 | 待填 |
| 物性调用 / 缓存命中率 | 待测 | 待填 |
@@ -425,7 +506,7 @@
**目标**:减少量纲差异造成的不必要小步和 Jacobian 重建,同时维持事件与守恒精度。
**当前状态**:模型中不同物理量的量级差异大。历史试验显示机械绝对容差放宽可能带来约 16% 收益,但属于精度策略变化;热流体固定点容差的简单放宽曾使表现变差,不能直接采用。
**当前状态**:模型中不同物理量的量级差异大。历史试验显示机械绝对容差放宽可能带来约 16% 收益,但属于精度策略变化;热流体固定点容差的简单放宽曾使表现变差,不能直接采用。当前已先完成不改变容差契约的可恢复试探步:按积分器实际 `h_abs` 对半退避,并在首次接受后恢复分段步长上限。Generic 显式 opt-in,即使模型无状态事件、断点或取消回调,也会进入支持重建的 stepwise 路径;普通 `integrate_ode` 调用者的默认路径不变。分量 `atol`、缩放和标准/快速配置仍未开始。
**工作项**:
@@ -435,6 +516,8 @@
- [ ] 统计限制步长的状态分量、误差拒步和 Jacobian 重建原因。
- [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。
- [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。
- [x] 对可恢复的热流体闭合失败使用积分器实际试探步 `h_abs` 对半回退;最多 16 次且不低于 64 ULP,恢复步仅设置 `first_step`,首次接受后恢复分段 `maxStep` 上限并记录 attempted/next step。
- [x] 为 eventless Generic 显式启用 `recoverable_trial_retries`,使没有状态事件、断点或取消回调的通用模型也能选择 stepwise 恢复;该参数默认关闭,避免改变其他调用者的直接 `solve_ivp` 语义。
**验收条件**:
@@ -487,7 +570,7 @@
**目标**:区分“内部慢步”和“真正无进度”,并让长任务可取消、可限流、不会拖垮服务进程。
**当前状态**:已有 stream 进度和取消检查;前端无进度阈值约 60 s。本次 2.05 s 附近可见最大间隔约 7.5 s,且中间有接受步与 CPU 活动,因此没有触发真实无进度条件。
**当前状态**:已有 stream 进度和取消检查;前端无进度阈值约 60 s。历史 2.05 s 附近可见最大间隔约 7.5 s,且中间有接受步与 CPU 活动,因此没有触发真实无进度条件。当前又补充了热流体失败位置、迭代尾部、最差端口及求解器恢复轨迹;`5 s / maxStep=0.05 s` 在 1200 s soft budget 后合作取消并保留 `t=4.2523535 s` 的部分诊断,属于预算终止而非 solver failure。单格 max-step 矩阵不再把“没有跨步长比较对”误判为失败。
**工作项**:
@@ -497,6 +580,9 @@
- [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。
- [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。
- [ ] 添加故意慢 RHS、死循环防护、客户端断连和多任务竞争测试。
- [x] 热流体失败记录 RHS 时刻、最近迭代尾部、最大增量/尺度/容差、最差端口及带符号差值,并保留求解器逐次恢复的 attempted/next step 与原因。
- [x] 矩阵报告分别记录外层 `soft_timeout` 和 worker 的合作 `cancelled`,避免把预算取消误记为求解器数值失败。
- [x] 单格 max-step 矩阵将空的跨步长比较集合视为“不适用”而非失败;最终 `2 s / 0.02 s` 单格复验整体通过且 `comparisonFailureCount=0`。
**验收条件**:
@@ -508,7 +594,42 @@
**目标**:用实测替代“0.81 s 或 2.10 s 可以外推到 10 s”的假设。
**当前状态**:`2.10 s` 已成功;`10 s` 尚未运行和建立资源预算。模型可能在后续出现新的事件、模式、接触切换或数值尺度问题。
**当前状态**:新主目标的 solver-only `1 s / maxStep=0.05 s` 已完成,worker 墙钟 `182.111 s`。修复前,`tStop=2 s` 与 `tStop=5 s` 在同一 `maxStep=0.05 s` 下具有相同的首次失败时刻和求解统计,均在 `t=1.859512845 s` 耗尽热流体外层 25 次;四档 `maxStep` 的失败时刻集中在 `1.8595–1.8603 s`。这说明远端 `tStop` 不是直接失败原因,它只决定运行是否到达该局部数值困难区。
PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复完成后,production `2 s` 的 `maxStep=0.01/0.02/0.05/0.10 s` 四个单元均到达 `2.0 s`,`caseFailureCount=0`。矩阵命令整体退出码仍为 1,原因是跨 `maxStep` 的严格状态一致性门未通过,而不是任何单元运行失败:差异集中在事件后的 8 个 MECMAS21 速度和 8 个加速度;在差异最大的一组跨 `maxStep` 终点比较中,绝对差约 `1.01e-6–1.12e-6`。`0.05/0.10 s` 两档则逐位一致。因此当前结论是“2 s 运行失败已解决”,但“跨步长数值等价”尚未签收,不能据此批准长时 golden。
`5 s / maxStep=0.02 s` 已完成,worker 墙钟 `696.418 s`,0 次可恢复重试,最大热流体迭代 19,`nfev/njev/nlu=18736/1347/4988`。`maxStep=0.05 s` 在 1200 s soft budget 后由 runner 合作取消,停止于 `t=4.2523535 s`,此前仅发生 1 次已成功恢复的试探步;它是有界预算结果,不是 solver failure,也不能与已完成的 `0.02 s` 单元做终点一致性签收。形成该阶段记录时,`10 s / maxStep=0.02 s` 尚在运行;完成结果及其后追加的通用接线复验见下方收口记录。
| `tStop` | `maxStep` | lane / 结果 | worker 墙钟或预算 | 可恢复重试 | 说明 |
| ---: | ---: | --- | ---: | ---: | --- |
| 1 s | 0.05 s | solver-only / 完成 | 182.111 s | —(旧版未记录) | 首次延长门通过 |
| 2 s | 0.01 s | production / 完成 | 324.727 s | 8 | 最大热流体迭代 19 |
| 2 s | 0.02 s | production / 完成 | 292.035 s | 0 | 首次 recovery 矩阵当时最快;最大热流体迭代 19 |
| 2 s | 0.05 s | production / 完成 | 450.425 s | 1 | 最大热流体迭代 18 |
| 2 s | 0.10 s | production / 完成 | 448.033 s | 1 | 与 0.05 s 路径逐位一致,上限未实际约束 |
| 2 s | 0.02 s | production / 最终通用接线复验完成 | 301.782 s | 0 | 2 次事件;单格矩阵整体通过 |
| 5 s | 0.02 s | production / 完成 | 696.418 s | 0 | 最大热流体迭代 19;`18736/1347/4988` |
| 5 s | 0.05 s | production / soft budget 合作取消 | 1200 s | 1 | 停止于 4.2523535 s;不是 solver failure |
| 10 s | 0.02 s | production / 历史:最终通用接线前单元完成 | 803.622 s | 0 | 接线前历史证据,不作为最终性能口径 |
| 10 s | 0.02 s | production / 最终通用接线后完成 | 1602.733 s | 1 | orchestration 1604.152 s;`45455/3075/15282`;接受步 9569;启动 6;事件 2 |
#### 2026-08-17 / PNL00R 正确性、热流体事务与实际步长恢复
- PNL00R 的端口温度参考改为同侧连接对端的温度参考焓:连接到 node 时使用对端组件的 `temperature_reference_h`,普通组件则使用常规 `connected_h`(即连接端口的 `h_outflow`);零容积元件自身的 `h_outflow` 仍保持对侧传播语义。42 项 PNL00R/stream 相关测试通过。
- 单次 RHS 事务会回滚物理端口、flow、物性缓存、因果绑定及相关诊断,防止失败试探点污染下一次尝试。只有热流体外层 25 次耗尽被分类为可恢复错误;`StreamSolveError` 和 secondary `AlgebraicSolveError` 仍保持致命错误语义。
- 事务开销的 7×100 RHS 微基准为关闭 `0.813488 s`、开启 `0.829156 s`,增加 `1.926%`,导数逐位一致。
- 聚焦组合回归共 163 项通过、1 项跳过。修复后 production `0.2 s` worker 墙钟 `128.296 s`,402 个 golden 值通过,最大绝对差 `0.0171461`、最大容差比 `0.151304`,output contract 不变。
- 证据:`runs/2026-08-17-production-thermofluid-recovery-v1-0.2.json`、`runs/2026-08-17-production-2s-max-step-matrix-v1.json`、`runs/2026-08-17-production-2s-max-step-matrix-recovery-v2.json`、`runs/2026-08-17-production-5s-max-step-matrix-recovery-v1.json`。
#### 2026-08-17 / 最终通用接线后的 `10 s` repeat 与收口
- 最终通用接线后的 `runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json` 完成到 `10.0 s`:worker 墙钟 `1602.733 s`、orchestration 墙钟 `1604.152 s`,`nfev/njev/nlu=45455/3075/15282`,接受步 9569,solver 启动 6 次,2 次状态事件。运行在 `t=6.9640458 s` 发生 1 次热流体可恢复失败并以 1 次重试继续完成,最大热流体迭代 23,最大缩放残差 `1.082e-16`;1717 条序列、1,722,151 个标量全部有限。
- `runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json` 的 worker `803.622 s` 结果明确属于上述两项最终通用接线之前的历史运行,只保留为阶段性正确性和故障定位证据,不作为最终版本的性能数据。
- 该接线前历史 10 s 报告的运行单元和 case acceptance 均通过,但旧版单格矩阵因 `sameHorizonAcrossMaxSteps=[]` 被空比较器误判,导致报告顶层 `passed=false` 和旧退出码 1;这不是仿真或数值验收失败。空比较器缺陷已经修复,最终接线后的 10 s repeat 与 `2 s / maxStep=0.02 s` 单格报告均整体 `passed=true`;后者另明确记录 `caseFailureCount=0`、`comparisonFailureCount=0`。
- 旧 10 s 报告生成时曾根据目标的状态事件与拓扑边界推断两项最终接线不会改变已覆盖边界;该推断作为历史说明保留,现在已由最终接线后的完整 10 s repeat 直接取代。
- 最终接线前后 `0.01 s` 输出逐值一致。两次 production `0.2 s` final candidate 运行也彼此逐值相同并均完成到终点,但两次对旧批准 golden 都只有 `398/402` 个值通过:同样的 4 个 `t=0.2 s` 派生 MECMAS21 加速度超出旧容差,最大容差比均为 `1.373`。因此不覆盖或重新批准旧 golden;应先独立确认派生加速度语义或调整投影契约。
- 最终 `2 s / maxStep=0.02 s` 复验 worker 墙钟 `301.782 s`,0 次热流体失败/可恢复重试,2 次状态事件,单格矩阵整体通过。真实 SciPy RK45/BDF 的 direct 与 opt-in stepwise A/B 在无失败时采样、状态及 `nfev/njev/nlu` 一致。完整 `unittest discover` 共 828 项,OK(3 项跳过)。
- 证据:`runs/2026-08-17-production-general-recovery-v2-smoke.json`、`runs/2026-08-17-production-general-recovery-v2-0.2.json`、`runs/2026-08-17-production-general-recovery-v2-repeat-0.2.json`、`runs/2026-08-17-production-2s-max-step-0p02-general-recovery-v3.json`、`runs/2026-08-17-production-10s-max-step-0p02-recovery-v1.json`、`runs/2026-08-17-production-10s-max-step-0p02-general-recovery-v3.json`。
**工作项**:
@@ -517,6 +638,7 @@
- [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。
- [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。
- [ ] 根据首次基线制定合理的 CI 频率和资源门槛。
- [x] 最终通用接线后的当前工作树完成首次 `10 s / maxStep=0.02 s` 单次运行并保存完整统计;连续 3 次验收仍待后续。
**验收条件**:
@@ -554,6 +676,8 @@
| 本文复杂 XML `0.81 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 主目标 `test-mql-8` `0.2 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 主目标 `test-mql-8` `1/5/10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
当前相关回归套件包括:
@@ -562,8 +686,14 @@
- `tests/test_pressure_flow_causal_execution.py`
- `tests/test_stream_pressure_block_solver.py`
- `tests/test_core_solver.py`
- `tests/test_causal_numeric_ir.py`
- `tests/test_thermofluid_recovery.py`
- `tests/test_amesim_pnl00r_component.py`
- `tests/test_stream_resolver_execution_plan.py`
- `tests/test_thermofluid_closure_plan.py`
- `tests/test_max_step_matrix.py`
这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。
这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。最终完整 `unittest discover` 共 828 项,OK(3 项跳过)。
## 7. 单项更新模板
@@ -593,6 +723,11 @@
| 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 验证 |
| 2026-08-17 | 工作树基于 `16a7eb2d`;备份 `backup/general-solver-v1-before-20260817-16a7eb2` | OPT-00/01/03/09 通用求解器 v1(历史输入) | 初版 `test-mql-8` runner/正确性门;默认低分配因果执行器 v2;名字无关的 8 支路/16 列半解析编译器 | 旧 SHA `42e2d627...` 下 0.01 s v1/v2 物理解逐位相同;0.2 s 全有限且 0 审计/回退失败 | v2 RHS 微基准 `-19.1%`;0.01 s 总墙钟 `-13.1%`;旧 0.2 s 132.305 s | v2 升为默认并保留 opt-out;旧报告标为 `historicalOnly`,不得生成新 golden |
| 2026-08-17 | 同一工作树;新权威 SHA `170463d6...` | OPT-00 P0 基础闭环 | 固化无空格 XML/JSON、参考依赖约束、runner v2、state golden、output contract、三层 CI 和有界延期决策 | production 0.2 s 全有限;402 个 golden 值逐项重放误差 0;信号分段/机械事件/残差/审计/回退门均通过;全量共 792 项,OK(3 项跳过) | worker 135.824 s;1 s 保守预测 1018.680 s,未启动 1/5/10 s | P0 基础设施完成,完整 OPT-00/09 仍部分实现;先优化算法,再恢复长时递进 |
| 2026-08-17 | 同一工作树 | OPT-01/02 因果坐标与参考 IR | `760` 个兼容槽压缩为 `440` 个逻辑坐标;独立 schema v1 参考 IR 覆盖 `112+328` 坐标和 320 个逻辑别名 | kernel on/off、兼容槽、状态导数、结构签名和逐阶段差分通过;审计/验证/回退失败均为 0 | Python 调用 `-36.8%`,RHS 微基准 `-12.9%`,production 0.2 s 单次 `-3.68%` | OPT-01 基本完成;IR 暂不接管默认热路径 |
| 2026-08-17 | 同一工作树 | OPT-00/04/05/08/09 热流体恢复与延长矩阵 | 修正 PNL00R 温度 stream 参考;加入 RHS 事务、类型化闭合失败、基于 `h_abs` 的对半重试和完整诊断 | production 0.2 s golden 通过;2 s 四档 `maxStep` 均完成且 `caseFailureCount=0`,但跨步长严格门因近零机械 `a/v` 差异未过;5 s 的 0.02 s 档完成,0.05 s 档为预算取消而非 solver failure | 2 s worker 墙钟为 324.727/292.035/450.425/448.033 s;5 s 的 0.02 s 档为 696.418 s、0 retry、`18736/1347/4988`,0.05 s 档在 1200 s 预算停止于 4.2523535 s | 原 1.86 s 致命失败已恢复;暂以 0.02 s 作为延长测试首选但不修改正式默认值或批准 golden;10 s 的 0.02 s 档进行中 |
| 2026-08-17 | 同一工作树;最终通用接线与 10 s repeat | OPT-04/05/08/09 `10 s` 最终收口 | eventless Generic opt-in stepwise recovery;StreamResolver 刷新全部温度参考 override;修复单格矩阵空比较器 | 0.01 s 接线前后逐值一致;两次 0.2 s final candidate 彼此逐值相同且均为旧 golden 398/402,同样 4 个终点派生 MECMAS21 `a` 超差、最大容差比 1.373,未覆盖 golden;最终 2 s 单格通过;真实 SciPy direct/stepwise A/B 等价;完整 unittest 828 项 OK(3 项跳过) | 最终接线后 10 s worker/orchestration 1602.733/1604.152 s,`45455/3075/15282`,接受步 9569、启动 6、事件 2;`t=6.9640458 s` 的 1 次热流体失败经 1 次重试恢复,最大迭代 23、残差 `1.082e-16`,1717 序列/1,722,151 标量全有限;最终 2 s worker 301.782 s | 最终通用接线后的 10 s 已完成;803.622 s 旧报告只作接线前历史证据、不作最终性能;旧 exit 1 仅为空比较器缺陷;旧 golden 保留,连续 3 次 10 s 仍待后续 |
## 9. 相关文档
@@ -51,3 +51,35 @@
- 根据同日其他项目会话的最终记录补全上述文档管理、性能诊断和求解器优化工作,并与当前源码、测试及优化任务账本交叉核对。
- 在当前项目 `.venv` 中重新运行 Jacobian、切向原语、三活塞、core solver、稀疏结构和 XML 仿真的定向测试,共 86 项全部通过;`git diff --check` 通过。
- 热流体闭合套件当前仍为 12/13,通过项不受影响;唯一失败是测试继续读取已经移动的旧 fixture 路径。测试资源移动属于用户操作,本日志未将其计入其他会话的完成成果。
## 19:40
- 将用户提供的 `test-mql-8` XML 接入主回归目标并锁定输入哈希;新增支持软取消、硬超时、预算判断、检查点、信号分段和机械事件验收的递进回归运行器,延长测试只在子进程内存中覆盖 `tStop`。
- 默认启用可回滚的因果执行器 v2,普通 RHS 不再重复构造集合或扫描全部 760 个代数未知量,secondary 块只维护 176 个必要 flow 槽;初始化、事件和每 64 次求解仍执行完整残差审计。
- 半解析 Jacobian 改为按组件类型和端口拓扑自动发现支路:该模型识别 8 条支路、16 个精确列,理论有限差分颜色数由 52 降至 36;短测因 40/56 次边界回退而慢于 SciPy,因此继续保留为显式实验模式,未设为默认。
- 同状态 RHS 微基准约提升 19.1%,`0.01 s` 端到端由 `15.160 s` 降至 `13.172 s`且物理解哈希一致;`0.2 s` solver-only 运行正常完成,审计、运行时验证和旧路径回退均为 0。
- 聚焦测试 143 项通过、1 项长测跳过;全量 779 项中 773 项通过、1 项跳过,其余 5 项为既有缺失 fixture。按当时 `0.2 s` 耗时预算,`1/5/10 s` 暂缓执行,checkpoint 和依赖环境尚未批准为发布基线。
## 20:01
- 完成大型工程导入后的端口几何重测,建模页与结果页分别记忆视口,仅首次打开、导入或加载时自动适配;多组件移动和复制支持整块自由端口吸附,并在拖动时立即断开块外接触连接。
- 新增点击端口接线、空白处添加折点、`Esc` 取消、路由写入工程 JSON、内部线段拖动及未连接交叉线的电路图式线桥。
- 结果曲线支持框选、单轴、撤销和自动缩放,并保持切页后的缩放状态;使用大型 `test-mql-8` 工程验证导入、适配和连线端点。
- 前端 E2E `104/104`、TypeScript 检查、生产构建和 `git diff --check` 全部通过;仅保留非阻断的 bundle 大小提示。
## 22:35
- 将用户提供的 `test-mql-8.xml` 与 `test-mql-8.json` 纳入权威回归输入校验,自动检查双哈希、模型结构和仿真配置;回归运行器支持独立覆盖 `tStop`、`sampleStep`、`maxStep`,并将 `production` 设为默认验收通道。
- 建立 `0.01 s` smoke 和 `0.2/1/5/10 s` 递进门禁、软硬超时及超预算暂缓;production `0.2 s` 正常完成,并批准包含 3 个检查点、134 个投影键、共 402 个状态值的 golden,另行校验输出形状合同。
- 增加 Python 3.12.3 与直接依赖参考约束,以及短测、历史模型夜间回归和主目标周期长测三层 CI;全量后端共运行 `792` 项测试,结果为 OK,其中 `3` 项按条件跳过,测试后无遗留仿真进程。
- P0 已形成支持后续优化的基础闭环,但仍缺关键压力、流量和守恒量投影、发布级完整依赖锁、干净环境重建、远端 CI 首次验证及正式环境三次 `2.10 s` 复测;按当时预算仍未启动 `1/5/10 s`。
## 22:40
- 确认 P0 门禁已足以进入 OPT-01,并允许启动 OPT-02 的小型参考数值 IR;后续扩围统一执行“结构测试 → `0.01 s` smoke → production `0.2 s` golden A/B → 性能对比”,但当前状态尚不代表求解器已完成发布验收。
## 22:48
- 修复同一组件不同端口引出线路交叉时漏画线桥的问题,并让结果页系统图复用线桥;连接线支持拖动水平段、竖直段和拐点,直线可自动转换为可调正交折线。
- 结果曲线增加以鼠标位置为中心的滚轮缩放、坐标轴单轴缩放、中键拖动平移、框选放大、越过数据范围及负数区域、恢复原始尺寸;缩放与游标互斥,空白视口禁用游标,并移除点击后的黑色焦点框。
- 使用 `test-mql-8.json` 完成专项验证;前端 E2E `109/109`、TypeScript 检查、项目 Node 24 下的生产构建和 `git diff --check` 全部通过。
@@ -0,0 +1,12 @@
# 更新日志 2026-08-18
## 02:28
- 完成 P0 基础回归闭环,并推进 OPT-01:将 760 个兼容代数槽压缩为 440 个逻辑坐标,逻辑消去 320 个 effort 别名;全局 RHS 微基准约提升 12.9%,Python 调用数约下降 36.8%。
- 建立独立可执行的因果数值 IR schema v1,分离结构程序与运行时绑定并支持逐阶段对照和事务回滚;该 IR 仍是参考实现,尚未接管默认 RHS 热路径。
- 修复 PNL00R 上游连接温度引用语义;为热流体闭合增加事务快照、类型化失败诊断、试探态回滚和基于实际试探步长的减步重试,并修复成功恢复后最大步长被永久限制的问题;无状态事件的 Generic 系统也可使用可恢复积分路径。
- `1 s`、`2 s`、`5 s` 和 `10 s` 递进长测均取得完成结果,`maxStep=0.02` 是当前长测首选;另一个 `5 s/maxStep=0.05` 单元在模拟时刻约 `4.252 s` 因 1200 秒预算合作取消,属于预算控制而非求解失败。
- 最终 `10 s/maxStep=0.02` 单次运行在约 `1602.7 s` 完成,1717 条序列、1,722,151 个标量全部有限并经历 2 次机械状态转换;模拟时刻约 `6.964 s` 的一次热流体试探态失败经事务回滚、减步和 1 次重试后继续完成,因果审计、运行时验证和旧路径回退均为 0。
- 相同 `maxStep=0.02` 的 `2 s` 与 `10 s` 运行在公共严格前缀检查点逐值一致,确认此前约 `1.86 s` 的失败来自不可恢复的试探态闭合处理,而不是远端 `tStop` 直接改变物理方程。
- 全量后端共运行 `828` 项测试,结果为 OK,其中 `3` 项按条件跳过;差异检查通过且测试后无遗留仿真进程。
- 当前仍有明确限制:跨最大步长严格比较尚未全部通过;最终 `0.2 s` 候选相对旧 golden 为 `398/402`,4 个超差项均是终点派生加速度,旧 golden 未被覆盖;最终 `10 s` 仅完成一次,仍需三次中位数、资源稳定性和新 golden 决策。
+815 -214
View File
File diff suppressed because it is too large. Load diff
+216 -9
View File
@@ -1,18 +1,59 @@
import {
useEffect,
useRef,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
BaseEdge,
getSmoothStepPath,
useReactFlow,
type EdgeProps,
type EdgeTypes,
} from "@xyflow/react";
import {
createOrthogonalSegmentDetour,
edgeSegmentAxis,
moveOrthogonalCorner,
moveOrthogonalSegment,
orthogonalEdgePoints,
orthogonalPolylinePath,
type EdgeRouteData,
type EdgeRoutePoint,
} from "./edgeRouting";
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
export function ContactAwareEdge({
type RouteDrag = {
basePoints: EdgeRoutePoint[];
cleanup: () => void;
kind: "corner" | "detour" | "segment";
pointerId: number;
routeIndex: number;
startPointer: EdgeRoutePoint;
};
function longestSegmentIndex(points: EdgeRoutePoint[]) {
let bestIndex = 0;
let bestLength = -1;
for (let index = 0; index < points.length - 1; index += 1) {
const length = Math.hypot(
points[index + 1].x - points[index].x,
points[index + 1].y - points[index].y,
);
if (length > bestLength) {
bestIndex = index;
bestLength = length;
}
}
return bestIndex;
}
function RoutedEdge({
data,
id,
interactionWidth,
markerEnd,
markerStart,
selected,
sourcePosition,
sourceX,
sourceY,
@@ -21,21 +62,119 @@ export function ContactAwareEdge({
targetX,
targetY,
}: EdgeProps) {
const isContactEdge = data?.isContactEdge === true;
if (isContactEdge) {
return null;
}
const [edgePath] = getSmoothStepPath({
const reactFlow = useReactFlow();
const dragRef = useRef<RouteDrag | null>(null);
const edgeData = (data ?? {}) as EdgeRouteData;
useEffect(
() => () => {
dragRef.current?.cleanup();
dragRef.current = null;
},
[],
);
const points = orthogonalEdgePoints({
routePoints: edgeData.routePoints,
sourcePosition,
sourceX,
sourceY,
targetPosition,
targetX,
targetY,
borderRadius: 0,
});
const edgePath = orthogonalPolylinePath(points, edgeData.crossingJumps);
const beginRouteDrag = (
event: ReactPointerEvent<SVGElement>,
basePoints: EdgeRoutePoint[],
kind: RouteDrag["kind"],
routeIndex: number,
) => {
event.preventDefault();
event.stopPropagation();
dragRef.current?.cleanup();
const drag: RouteDrag = {
basePoints: basePoints.map((point) => ({ ...point })),
cleanup: () => undefined,
kind,
pointerId: event.pointerId,
routeIndex,
startPointer: reactFlow.screenToFlowPosition(
{ x: event.clientX, y: event.clientY },
{ snapToGrid: false },
),
};
const moveRoute = (pointerEvent: PointerEvent) => {
if (
dragRef.current !== drag ||
pointerEvent.pointerId !== drag.pointerId
) {
return;
}
pointerEvent.preventDefault();
pointerEvent.stopPropagation();
const pointer = reactFlow.screenToFlowPosition(
{ x: pointerEvent.clientX, y: pointerEvent.clientY },
{ snapToGrid: false },
);
const delta = {
x: pointer.x - drag.startPointer.x,
y: pointer.y - drag.startPointer.y,
};
const nextPoints =
drag.kind === "corner"
? moveOrthogonalCorner(drag.basePoints, drag.routeIndex, delta)
: drag.kind === "detour"
? createOrthogonalSegmentDetour(
drag.basePoints,
drag.routeIndex,
delta,
)
: moveOrthogonalSegment(
drag.basePoints,
drag.routeIndex,
delta,
);
edgeData.onRoutePointsChange?.(id, nextPoints.slice(1, -1));
};
const finishRoute = (pointerEvent: PointerEvent) => {
if (
dragRef.current !== drag ||
pointerEvent.pointerId !== drag.pointerId
) {
return;
}
pointerEvent.preventDefault();
pointerEvent.stopPropagation();
drag.cleanup();
dragRef.current = null;
};
drag.cleanup = () => {
window.removeEventListener("pointermove", moveRoute);
window.removeEventListener("pointerup", finishRoute);
window.removeEventListener("pointercancel", finishRoute);
};
dragRef.current = drag;
window.addEventListener("pointermove", moveRoute, { passive: false });
window.addEventListener("pointerup", finishRoute);
window.addEventListener("pointercancel", finishRoute);
edgeData.onRouteEditStart?.(id);
};
const editable = selected && edgeData.editable === true;
const internalSegments = points
.slice(0, -1)
.map((start, index) => ({ end: points[index + 1], index, start }))
.filter(({ index }) => index > 0 && index < points.length - 2);
const internalCorners = points
.map((point, index) => ({ index, point }))
.filter(({ index }) => index > 0 && index < points.length - 1);
const fallbackIndex = longestSegmentIndex(points);
const fallbackStart = points[fallbackIndex];
const fallbackEnd = points[fallbackIndex + 1];
return (
<>
<BaseEdge
id={id}
interactionWidth={interactionWidth}
@@ -44,9 +183,77 @@ export function ContactAwareEdge({
path={edgePath}
style={style}
/>
{editable
? internalSegments.map(({ end, index, start }) => (
<g
className="manual-edge-segment-control"
key={`${id}-segment-${index}`}
>
<line
aria-label={`调整连接线段 ${index}`}
className={`manual-edge-segment-handle ${edgeSegmentAxis(start, end)}`}
data-edge-id={id}
data-segment-index={index}
onPointerDown={(event) =>
beginRouteDrag(event, points, "segment", index)
}
x1={start.x}
x2={end.x}
y1={start.y}
y2={end.y}
/>
<line
aria-hidden="true"
className="manual-edge-segment-guide"
x1={start.x}
x2={end.x}
y1={start.y}
y2={end.y}
/>
</g>
))
: null}
{editable
? internalCorners.map(({ index, point }) => (
<circle
aria-label={`双向调整连接线拐点 ${index}`}
className="manual-edge-corner-handle"
cx={point.x}
cy={point.y}
data-corner-index={index}
data-edge-id={id}
key={`${id}-corner-${index}`}
onPointerDown={(event) =>
beginRouteDrag(event, points, "corner", index)
}
r={4.5}
/>
))
: null}
{editable && internalCorners.length === 0 && fallbackEnd ? (
<circle
aria-label="双向调整连接线路由"
className="manual-edge-route-handle"
cx={(fallbackStart.x + fallbackEnd.x) / 2}
cy={(fallbackStart.y + fallbackEnd.y) / 2}
data-edge-id={id}
onPointerDown={(event) =>
beginRouteDrag(event, points, "detour", fallbackIndex)
}
r={5}
/>
) : null}
</>
);
}
export function ContactAwareEdge(props: EdgeProps) {
if (props.data?.isContactEdge === true) {
return null;
}
return <RoutedEdge {...props} />;
}
export const contactAwareEdgeTypes: EdgeTypes = {
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
};
File diff suppressed because it is too large. Load diff
+536
View File
@@ -0,0 +1,536 @@
import { Position } from "@xyflow/react";
export type EdgeRoutePoint = {
x: number;
y: number;
};
export type EdgeCrossingJump = EdgeRoutePoint & {
orientation: "horizontal" | "vertical";
};
export type EdgeRouteData = {
crossingJumps?: EdgeCrossingJump[];
editable?: boolean;
isContactEdge?: boolean;
onRouteEditStart?: (edgeId: string) => void;
onRoutePointsChange?: (edgeId: string, routePoints: EdgeRoutePoint[]) => void;
routePoints?: EdgeRoutePoint[];
};
export type EdgePointAxis = "horizontal" | "vertical";
const ROUTE_EPSILON = 0.01;
const CROSSING_JUMP_RADIUS = 6;
export function edgeAxisForPosition(position: Position): EdgePointAxis {
return position === Position.Left || position === Position.Right
? "horizontal"
: "vertical";
}
export function edgePointIsFinite(point: EdgeRoutePoint) {
return Number.isFinite(point.x) && Number.isFinite(point.y);
}
export function edgePointsMatch(
first: EdgeRoutePoint,
second: EdgeRoutePoint,
epsilon = ROUTE_EPSILON,
) {
return (
Math.abs(first.x - second.x) <= epsilon &&
Math.abs(first.y - second.y) <= epsilon
);
}
function appendDistinctPoint(
points: EdgeRoutePoint[],
point: EdgeRoutePoint,
) {
if (!edgePointIsFinite(point)) {
return;
}
const previous = points.at(-1);
if (!previous || !edgePointsMatch(previous, point)) {
points.push({ x: point.x, y: point.y });
}
}
export function simplifyOrthogonalPoints(points: EdgeRoutePoint[]) {
const distinct: EdgeRoutePoint[] = [];
points.forEach((point) => appendDistinctPoint(distinct, point));
if (distinct.length <= 2) {
return distinct;
}
const simplified: EdgeRoutePoint[] = [distinct[0]];
for (let index = 1; index < distinct.length - 1; index += 1) {
const previous = simplified.at(-1) as EdgeRoutePoint;
const current = distinct[index];
const next = distinct[index + 1];
const collinearX =
Math.abs(previous.x - current.x) <= ROUTE_EPSILON &&
Math.abs(current.x - next.x) <= ROUTE_EPSILON;
const collinearY =
Math.abs(previous.y - current.y) <= ROUTE_EPSILON &&
Math.abs(current.y - next.y) <= ROUTE_EPSILON;
if (!collinearX && !collinearY) {
simplified.push(current);
}
}
simplified.push(distinct.at(-1) as EdgeRoutePoint);
return simplified;
}
function appendOrthogonalLeg(
points: EdgeRoutePoint[],
target: EdgeRoutePoint,
firstAxis: EdgePointAxis,
) {
const source = points.at(-1);
if (!source || edgePointsMatch(source, target)) {
appendDistinctPoint(points, target);
return;
}
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
if (changesX && changesY) {
appendDistinctPoint(
points,
firstAxis === "horizontal"
? { x: target.x, y: source.y }
: { x: source.x, y: target.y },
);
}
appendDistinctPoint(points, target);
}
function appendEndpointAwareLeg(
points: EdgeRoutePoint[],
target: EdgeRoutePoint,
sourceAxis: EdgePointAxis,
targetAxis: EdgePointAxis,
) {
const source = points.at(-1);
if (!source || edgePointsMatch(source, target)) {
appendDistinctPoint(points, target);
return;
}
const changesX = Math.abs(source.x - target.x) > ROUTE_EPSILON;
const changesY = Math.abs(source.y - target.y) > ROUTE_EPSILON;
if (!changesX || !changesY) {
appendDistinctPoint(points, target);
return;
}
if (sourceAxis !== targetAxis) {
appendDistinctPoint(
points,
sourceAxis === "horizontal"
? { x: target.x, y: source.y }
: { x: source.x, y: target.y },
);
} else if (sourceAxis === "horizontal") {
const middleX = (source.x + target.x) / 2;
appendDistinctPoint(points, { x: middleX, y: source.y });
appendDistinctPoint(points, { x: middleX, y: target.y });
} else {
const middleY = (source.y + target.y) / 2;
appendDistinctPoint(points, { x: source.x, y: middleY });
appendDistinctPoint(points, { x: target.x, y: middleY });
}
appendDistinctPoint(points, target);
}
/** Builds a persisted orthogonal route through each exact user waypoint. */
export function buildOrthogonalRoutePoints(
source: EdgeRoutePoint,
target: EdgeRoutePoint,
sourcePosition: Position,
targetPosition: Position,
waypoints: EdgeRoutePoint[] = [],
) {
const sourceAxis = edgeAxisForPosition(sourcePosition);
const targetAxis = edgeAxisForPosition(targetPosition);
const points: EdgeRoutePoint[] = [{ ...source }];
let nextAxis = sourceAxis;
waypoints.filter(edgePointIsFinite).forEach((waypoint) => {
appendOrthogonalLeg(points, waypoint, nextAxis);
nextAxis = nextAxis === "horizontal" ? "vertical" : "horizontal";
});
appendEndpointAwareLeg(points, target, nextAxis, targetAxis);
return simplifyOrthogonalPoints(points).slice(1, -1);
}
/** Returns the rendered polyline, including both live port endpoints. */
export function orthogonalEdgePoints({
routePoints,
sourcePosition,
sourceX,
sourceY,
targetPosition,
targetX,
targetY,
}: {
routePoints?: EdgeRoutePoint[];
sourcePosition: Position;
sourceX: number;
sourceY: number;
targetPosition: Position;
targetX: number;
targetY: number;
}) {
const source = { x: sourceX, y: sourceY };
const target = { x: targetX, y: targetY };
const storedPoints = routePoints?.filter(edgePointIsFinite) ?? [];
if (storedPoints.length === 0) {
return [
source,
...buildOrthogonalRoutePoints(
source,
target,
sourcePosition,
targetPosition,
),
target,
];
}
const points: EdgeRoutePoint[] = [source];
const first = storedPoints[0];
if (
Math.abs(source.x - first.x) > ROUTE_EPSILON &&
Math.abs(source.y - first.y) > ROUTE_EPSILON
) {
appendDistinctPoint(
points,
edgeAxisForPosition(sourcePosition) === "horizontal"
? { x: first.x, y: source.y }
: { x: source.x, y: first.y },
);
}
storedPoints.forEach((point) => appendDistinctPoint(points, point));
const last = points.at(-1) as EdgeRoutePoint;
if (
Math.abs(last.x - target.x) > ROUTE_EPSILON &&
Math.abs(last.y - target.y) > ROUTE_EPSILON
) {
appendDistinctPoint(
points,
edgeAxisForPosition(targetPosition) === "horizontal"
? { x: last.x, y: target.y }
: { x: target.x, y: last.y },
);
}
appendDistinctPoint(points, target);
return simplifyOrthogonalPoints(points);
}
function pointOnSegmentInterior(
point: EdgeRoutePoint,
start: EdgeRoutePoint,
end: EdgeRoutePoint,
) {
const segmentLength = Math.hypot(end.x - start.x, end.y - start.y);
const fromStart = Math.hypot(point.x - start.x, point.y - start.y);
const fromEnd = Math.hypot(point.x - end.x, point.y - end.y);
return (
segmentLength > CROSSING_JUMP_RADIUS * 2 + 2 &&
fromStart > CROSSING_JUMP_RADIUS + 1 &&
fromEnd > CROSSING_JUMP_RADIUS + 1 &&
Math.abs(fromStart + fromEnd - segmentLength) <= 0.2
);
}
export function edgeSegmentAxis(
start: EdgeRoutePoint,
end: EdgeRoutePoint,
): EdgePointAxis {
return Math.abs(start.x - end.x) >= Math.abs(start.y - end.y)
? "horizontal"
: "vertical";
}
export function orthogonalPolylinePath(
points: EdgeRoutePoint[],
crossingJumps: EdgeCrossingJump[] = [],
) {
if (points.length === 0) {
return "";
}
let path = `M ${points[0].x} ${points[0].y}`;
for (let index = 0; index < points.length - 1; index += 1) {
const start = points[index];
const end = points[index + 1];
const orientation = edgeSegmentAxis(start, end);
const horizontal = orientation === "horizontal";
const direction = horizontal
? Math.sign(end.x - start.x)
: Math.sign(end.y - start.y);
if (direction === 0) {
continue;
}
const jumps = crossingJumps
.filter(
(jump) =>
jump.orientation === orientation &&
pointOnSegmentInterior(jump, start, end),
)
.sort((first, second) =>
horizontal
? (first.x - second.x) * direction
: (first.y - second.y) * direction,
);
jumps.forEach((jump) => {
const before = horizontal
? { x: jump.x - direction * CROSSING_JUMP_RADIUS, y: start.y }
: { x: start.x, y: jump.y - direction * CROSSING_JUMP_RADIUS };
const after = horizontal
? { x: jump.x + direction * CROSSING_JUMP_RADIUS, y: start.y }
: { x: start.x, y: jump.y + direction * CROSSING_JUMP_RADIUS };
path += ` L ${before.x} ${before.y}`;
path += horizontal
? ` Q ${jump.x} ${jump.y - CROSSING_JUMP_RADIUS * 1.8} ${after.x} ${after.y}`
: ` Q ${jump.x + CROSSING_JUMP_RADIUS * 1.8} ${jump.y} ${after.x} ${after.y}`;
});
path += ` L ${end.x} ${end.y}`;
}
return path;
}
export type RoutedEdgeCrossingInput = {
id: string;
nodeIds: readonly [string, string];
points: EdgeRoutePoint[];
};
function valueInsideSegment(
value: number,
first: number,
second: number,
margin = CROSSING_JUMP_RADIUS + 2,
) {
const minimum = Math.min(first, second) + margin;
const maximum = Math.max(first, second) - margin;
return value > minimum && value < maximum;
}
/**
* Derives display-only circuit-style jump arcs. The horizontal route is chosen
* consistently, so recalculation never makes the bridge alternate between
* lines as the user edits nearby geometry.
*/
export function detectEdgeCrossingJumps(
edges: RoutedEdgeCrossingInput[],
) {
const jumpsByEdgeId = new Map<string, EdgeCrossingJump[]>();
for (let firstIndex = 0; firstIndex < edges.length; firstIndex += 1) {
const first = edges[firstIndex];
for (let secondIndex = firstIndex + 1; secondIndex < edges.length; secondIndex += 1) {
const second = edges[secondIndex];
for (let firstSegment = 0; firstSegment < first.points.length - 1; firstSegment += 1) {
const firstStart = first.points[firstSegment];
const firstEnd = first.points[firstSegment + 1];
const firstAxis = edgeSegmentAxis(firstStart, firstEnd);
for (let secondSegment = 0; secondSegment < second.points.length - 1; secondSegment += 1) {
const secondStart = second.points[secondSegment];
const secondEnd = second.points[secondSegment + 1];
const secondAxis = edgeSegmentAxis(secondStart, secondEnd);
if (firstAxis === secondAxis) {
continue;
}
const horizontal =
firstAxis === "horizontal"
? { edge: first, start: firstStart, end: firstEnd }
: { edge: second, start: secondStart, end: secondEnd };
const vertical =
firstAxis === "vertical"
? { start: firstStart, end: firstEnd }
: { start: secondStart, end: secondEnd };
const crossing = { x: vertical.start.x, y: horizontal.start.y };
if (
!valueInsideSegment(
crossing.x,
horizontal.start.x,
horizontal.end.x,
) ||
!valueInsideSegment(
crossing.y,
vertical.start.y,
vertical.end.y,
)
) {
continue;
}
const current = jumpsByEdgeId.get(horizontal.edge.id) ?? [];
if (
!current.some(
(jump) =>
Math.abs(jump.x - crossing.x) <= ROUTE_EPSILON &&
Math.abs(jump.y - crossing.y) <= ROUTE_EPSILON,
)
) {
current.push({ ...crossing, orientation: "horizontal" });
jumpsByEdgeId.set(horizontal.edge.id, current);
}
}
}
}
}
return jumpsByEdgeId;
}
/**
* Moves an internal orthogonal segment along its perpendicular axis. Both live
* endpoints remain untouched; the neighbouring perpendicular legs absorb the
* displacement.
*/
export function moveOrthogonalSegment(
points: EdgeRoutePoint[],
segmentIndex: number,
delta: EdgeRoutePoint,
) {
const nextPoints = points.map((point) => ({ ...point }));
if (segmentIndex <= 0 || segmentIndex >= nextPoints.length - 2) {
return nextPoints;
}
const start = nextPoints[segmentIndex];
const end = nextPoints[segmentIndex + 1];
if (edgeSegmentAxis(start, end) === "horizontal") {
start.y += delta.y;
end.y += delta.y;
} else {
start.x += delta.x;
end.x += delta.x;
}
return simplifyOrthogonalPoints(nextPoints);
}
/**
* Moves an internal bend in both axes while preserving an orthogonal route.
* Moving the bend also slides its two neighbouring bends along their existing
* perpendicular legs, which keeps the two live port endpoints fixed.
*/
export function moveOrthogonalCorner(
points: EdgeRoutePoint[],
cornerIndex: number,
delta: EdgeRoutePoint,
) {
const nextPoints = points.map((point) => ({ ...point }));
if (cornerIndex <= 0 || cornerIndex >= nextPoints.length - 1) {
return nextPoints;
}
const previous = nextPoints[cornerIndex - 1];
const corner = nextPoints[cornerIndex];
const incomingAxis = edgeSegmentAxis(previous, corner);
const outgoingAxis = edgeSegmentAxis(
corner,
nextPoints[cornerIndex + 1],
);
corner.x += delta.x;
corner.y += delta.y;
if (cornerIndex > 1) {
if (incomingAxis === "horizontal") {
previous.y = corner.y;
} else {
previous.x = corner.x;
}
} else if (incomingAxis === "horizontal") {
const middleX =
Math.abs(previous.x - corner.x) > ROUTE_EPSILON
? (previous.x + corner.x) / 2
: previous.x + 20;
nextPoints.splice(
cornerIndex,
0,
{ x: middleX, y: previous.y },
{ x: middleX, y: corner.y },
);
cornerIndex += 2;
} else {
const middleY =
Math.abs(previous.y - corner.y) > ROUTE_EPSILON
? (previous.y + corner.y) / 2
: previous.y + 20;
nextPoints.splice(
cornerIndex,
0,
{ x: previous.x, y: middleY },
{ x: corner.x, y: middleY },
);
cornerIndex += 2;
}
const movedCorner = nextPoints[cornerIndex];
const following = nextPoints[cornerIndex + 1];
if (cornerIndex < nextPoints.length - 2) {
if (outgoingAxis === "horizontal") {
following.y = movedCorner.y;
} else {
following.x = movedCorner.x;
}
} else if (outgoingAxis === "horizontal") {
const middleX =
Math.abs(movedCorner.x - following.x) > ROUTE_EPSILON
? (movedCorner.x + following.x) / 2
: following.x - 20;
nextPoints.splice(
cornerIndex + 1,
0,
{ x: middleX, y: movedCorner.y },
{ x: middleX, y: following.y },
);
} else {
const middleY =
Math.abs(movedCorner.y - following.y) > ROUTE_EPSILON
? (movedCorner.y + following.y) / 2
: following.y - 20;
nextPoints.splice(
cornerIndex + 1,
0,
{ x: movedCorner.x, y: middleY },
{ x: following.x, y: middleY },
);
}
return simplifyOrthogonalPoints(nextPoints);
}
/** Creates a movable dogleg when a selected route is still a straight line. */
export function createOrthogonalSegmentDetour(
points: EdgeRoutePoint[],
segmentIndex: number,
delta: EdgeRoutePoint,
) {
const start = points[segmentIndex];
const end = points[segmentIndex + 1];
if (!start || !end) {
return points.map((point) => ({ ...point }));
}
const firstThird = {
x: start.x + (end.x - start.x) / 3,
y: start.y + (end.y - start.y) / 3,
};
const secondThird = {
x: start.x + ((end.x - start.x) * 2) / 3,
y: start.y + ((end.y - start.y) * 2) / 3,
};
const detour =
edgeSegmentAxis(start, end) === "horizontal"
? [
{ x: firstThird.x + delta.x, y: start.y },
{ x: firstThird.x + delta.x, y: start.y + delta.y },
{ x: secondThird.x + delta.x, y: end.y + delta.y },
{ x: secondThird.x + delta.x, y: end.y },
]
: [
{ x: start.x, y: firstThird.y + delta.y },
{ x: start.x + delta.x, y: firstThird.y + delta.y },
{ x: end.x + delta.x, y: secondThird.y + delta.y },
{ x: end.x, y: secondThird.y + delta.y },
];
return simplifyOrthogonalPoints([
...points.slice(0, segmentIndex + 1),
...detour,
...points.slice(segmentIndex + 1),
]);
}
+130
View File
@@ -3647,3 +3647,133 @@ textarea {
padding-right: 8px;
}
}
/* Result chart viewport controls and AMESim-style box zoom. */
.result-chart-window-header button:disabled,
.result-chart-window-header button:disabled:hover {
border-color: transparent;
color: #8a97a6;
background: transparent;
cursor: default;
opacity: 0.38;
}
.result-chart-window-header button.zoom.active {
border-color: #74a9d7;
color: #0f5f9f;
background: #e8f3fc;
}
.result-chart-body > svg.zoom-enabled {
cursor: crosshair;
}
.result-chart-body > svg.zoom-enabled[data-panning="true"],
.result-chart-body > svg.zoom-enabled[data-panning="true"] .result-chart-zoom-axis-hit {
cursor: grabbing;
}
.result-chart-zoom-axis-hit {
pointer-events: all;
}
.result-chart-zoom-axis-hit.x {
cursor: ew-resize;
}
.result-chart-zoom-axis-hit.y {
cursor: ns-resize;
}
.result-chart-zoom-axis-hit:focus {
outline: none;
}
.result-chart-body > svg:focus,
.result-chart-body > svg:focus-visible {
outline: none;
}
.result-chart-zoom-selection {
fill: rgba(29, 111, 184, 0.16);
stroke: #1d6fb8;
stroke-width: 1;
stroke-dasharray: 4 3;
vector-effect: non-scaling-stroke;
}
/* AMESim-style click routing, segment editing and circuit crossing bridges. */
.manual-connection-draft {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
z-index: 7;
}
.manual-connection-draft .react-flow__connection-path {
fill: none;
stroke: #1675c1;
stroke-width: 2;
stroke-dasharray: 5 4;
vector-effect: non-scaling-stroke;
}
.manual-connection-waypoint {
fill: #ffffff;
stroke: #1675c1;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
}
.manual-edge-segment-handle {
stroke: transparent;
stroke-width: 14;
pointer-events: stroke;
touch-action: none;
vector-effect: non-scaling-stroke;
}
.manual-edge-segment-guide {
stroke: rgba(22, 117, 193, 0.34);
stroke-width: 1.25;
stroke-dasharray: 4 3;
pointer-events: none;
vector-effect: non-scaling-stroke;
}
.manual-edge-segment-handle:hover + .manual-edge-segment-guide,
.manual-edge-segment-handle:active + .manual-edge-segment-guide {
stroke: rgba(15, 95, 159, 0.78);
stroke-width: 2;
}
.manual-edge-segment-handle.horizontal {
cursor: ns-resize;
}
.manual-edge-segment-handle.vertical {
cursor: ew-resize;
}
.manual-edge-route-handle,
.manual-edge-corner-handle {
fill: #ffffff;
stroke: #1675c1;
stroke-width: 2;
cursor: move;
pointer-events: all;
touch-action: none;
vector-effect: non-scaling-stroke;
}
.manual-edge-corner-handle:hover,
.manual-edge-route-handle:hover {
fill: #d9edff;
stroke: #0f5f9f;
}
.flow-canvas.connection-planning .react-flow__pane {
cursor: crosshair;
}
@@ -3077,10 +3077,25 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 20, y: 20 },
});
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"origin",
);
const manualDraft = page.locator(".flow-canvas .manual-connection-draft");
await expect(manualDraft).toBeVisible();
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
"data-waypoint-count",
"1",
);
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(firstPhysicalOutput).toHaveAttribute(
"data-connection-state",
"idle",
);
await expect(manualDraft).toHaveCount(0);
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(compatiblePhysicalPort).toHaveAttribute(
@@ -3094,8 +3109,16 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
);
await firstPhysicalOutput.click();
await page.locator(".flow-canvas .react-flow__pane").click({
position: { x: 70, y: 90 },
});
await expect(manualDraft.locator(".react-flow__connection-path")).toHaveAttribute(
"data-waypoint-count",
"1",
);
await compatiblePhysicalPort.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(manualDraft).toHaveCount(0);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
@@ -3107,6 +3130,15 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expect(physicalEdge.locator(".edge-endpoint-blocker")).toHaveCount(0);
await expect(physicalEdge.locator(".react-flow__edge-interaction")).toHaveCount(1);
await expect(physicalEdge).not.toHaveClass(/editor-edge-contact/);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const routedEdge = await page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:project:demo-system",
);
const project = raw ? JSON.parse(raw) : null;
return project?.edges?.[0] ?? null;
});
expect(routedEdge?.data?.routePoints?.length).toBeGreaterThan(0);
await signalOutput.click();
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
@@ -0,0 +1,719 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { prepareApp, wideProject } from "./fixtures";
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
test.beforeEach(async ({ page }) => {
await prepareApp(page);
});
async function locatorCenter(locator: Locator) {
const bounds = await locator.boundingBox();
expect(bounds).not.toBeNull();
return {
x: bounds!.x + bounds!.width / 2,
y: bounds!.y + bounds!.height / 2,
};
}
async function distanceBetween(first: Locator, second: Locator) {
const [firstCenter, secondCenter] = await Promise.all([
locatorCenter(first),
locatorCenter(second),
]);
return Math.hypot(
secondCenter.x - firstCenter.x,
secondCenter.y - firstCenter.y,
);
}
async function dragGenericToCanvas(
page: Page,
targetPosition: { x: number; y: number },
) {
const currentCount = await page
.locator('.flow-canvas .react-flow__node[data-id^="generic_sensor_"]')
.count();
await page
.getByRole("button", { name: /通用测试元件/ })
.dragTo(page.locator(".flow-canvas .react-flow__pane"), { targetPosition });
const node = page.locator(
`.flow-canvas .react-flow__node[data-id="generic_sensor_${currentCount + 1}"]`,
);
await expect(node).toBeVisible();
return node;
}
async function moveNodePortNearTarget(
page: Page,
movingNode: Locator,
movingPort: Locator,
targetPort: Locator,
remainingGap = 8,
) {
const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([
locatorCenter(movingNode.locator(".sim-node")),
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const deltaX = targetPortCenter.x - movingPortCenter.x;
const deltaY = targetPortCenter.y - movingPortCenter.y;
const distance = Math.hypot(deltaX, deltaY);
const ratio = (distance - remainingGap) / distance;
let pointerX = dragStart.x + deltaX * ratio;
let pointerY = dragStart.y + deltaY * ratio;
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
await page.mouse.move(pointerX, pointerY, { steps: 12 });
for (let attempt = 0; attempt < 2; attempt += 1) {
const [movingCenter, targetCenter] = await Promise.all([
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const correctionX = targetCenter.x - movingCenter.x;
const correctionY = targetCenter.y - movingCenter.y;
const correctionDistance = Math.hypot(correctionX, correctionY);
if (correctionDistance <= remainingGap + 1) {
break;
}
const correctionRatio =
(correctionDistance - remainingGap) / correctionDistance;
pointerX += correctionX * correctionRatio;
pointerY += correctionY * correctionRatio;
await page.mouse.move(pointerX, pointerY, { steps: 4 });
}
return { pointerX, pointerY };
}
async function movePointerUntilPortsMeet(
page: Page,
pointer: { x: number; y: number },
movingPort: Locator,
targetPort: Locator,
) {
const nextPointer = { ...pointer };
for (let attempt = 0; attempt < 3; attempt += 1) {
const [movingCenter, targetCenter] = await Promise.all([
locatorCenter(movingPort),
locatorCenter(targetPort),
]);
const correction = {
x: targetCenter.x - movingCenter.x,
y: targetCenter.y - movingCenter.y,
};
if (Math.hypot(correction.x, correction.y) < 2) {
break;
}
nextPointer.x += correction.x;
nextPointer.y += correction.y;
await page.mouse.move(nextPointer.x, nextPointer.y, { steps: 6 });
}
return nextPointer;
}
function projectNode(id: string, x: number, y: number) {
const template = structuredClone(wideProject.nodes[0]);
return {
...template,
id,
position: { x, y },
data: {
...template.data,
label: id,
},
};
}
test("多选块拖动会即时断开边界接触,并由块内空闲端口整体吸附", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
const secondMovingNode = await dragGenericToCanvas(page, { x: 410, y: 430 });
const snapTargetNode = await dragGenericToCanvas(page, { x: 720, y: 430 });
const fixedPort = fixedNode.locator('.port-handle[data-port-name="port_b"]');
const boundaryPort = firstMovingNode.locator(
'.port-handle[data-port-name="port_a"]',
);
const blockFreePort = secondMovingNode.locator(
'.port-handle[data-port-name="port_b"]',
);
const targetFreePort = snapTargetNode.locator(
'.port-handle[data-port-name="port_a"]',
);
await moveNodePortNearTarget(
page,
firstMovingNode,
boundaryPort,
fixedPort,
);
await page.mouse.up();
await expect.poll(() => distanceBetween(boundaryPort, fixedPort)).toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await firstMovingNode.locator(".sim-node").click();
await secondMovingNode.locator(".sim-node").click({ modifiers: ["Control"] });
await expect(firstMovingNode).toHaveClass(/selected/);
await expect(secondMovingNode).toHaveClass(/selected/);
const [firstBefore, secondBefore] = await Promise.all([
firstMovingNode.boundingBox(),
secondMovingNode.boundingBox(),
]);
expect(firstBefore).not.toBeNull();
expect(secondBefore).not.toBeNull();
await moveNodePortNearTarget(
page,
firstMovingNode,
blockFreePort,
targetFreePort,
);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await expect(blockFreePort).toHaveAttribute("data-connection-state", "origin");
await expect(targetFreePort).toHaveAttribute(
"data-connection-state",
"compatible",
);
await page.mouse.up();
await expect.poll(() => distanceBetween(blockFreePort, targetFreePort)).toBeLessThan(2);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
await expect(boundaryPort).toHaveAttribute("data-connected", "false");
await expect(fixedPort).toHaveAttribute("data-connected", "false");
await expect(blockFreePort).toHaveAttribute("data-connected", "true");
await expect(targetFreePort).toHaveAttribute("data-connected", "true");
const [firstAfter, secondAfter] = await Promise.all([
firstMovingNode.boundingBox(),
secondMovingNode.boundingBox(),
]);
expect(firstAfter).not.toBeNull();
expect(secondAfter).not.toBeNull();
expect(firstAfter!.x - firstBefore!.x).toBeCloseTo(
secondAfter!.x - secondBefore!.x,
1,
);
expect(firstAfter!.y - firstBefore!.y).toBeCloseTo(
secondAfter!.y - secondBefore!.y,
1,
);
});
test("复制块保留内部连接,同时未连接端口仍参与待放置吸附", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
const target = await dragGenericToCanvas(page, { x: 720, y: 370 });
await first.locator('.port-handle[data-port-name="port_b"]').click();
await second.locator('.port-handle[data-port-name="port_a"]').click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await first.locator(".sim-node").click();
await second.locator(".sim-node").click({ modifiers: ["Control"] });
await expect(first).toHaveClass(/selected/);
await expect(second).toHaveClass(/selected/);
await page.keyboard.press("Control+c");
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
expect(paneBounds).not.toBeNull();
let pointer = {
x: paneBounds!.x + paneBounds!.width * 0.5,
y: paneBounds!.y + paneBounds!.height * 0.72,
};
await page.mouse.move(pointer.x, pointer.y);
await page.keyboard.press("Control+v");
const firstPreview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_4"]',
);
const secondPreview = page.locator(
'.flow-canvas .react-flow__node[data-id="generic_sensor_5"]',
);
await expect(firstPreview).toHaveClass(/pending-paste-node/);
await expect(secondPreview).toHaveClass(/pending-paste-node/);
await expect(page.locator(".flow-canvas .pending-paste-edge")).toHaveCount(1);
await expect(
firstPreview.locator('.port-handle[data-port-name="port_b"]'),
).toHaveAttribute("data-connection-state", "connected");
await expect(
secondPreview.locator('.port-handle[data-port-name="port_a"]'),
).toHaveAttribute("data-connection-state", "connected");
const copiedFreePort = secondPreview.locator(
'.port-handle[data-port-name="port_b"]',
);
const targetPort = target.locator('.port-handle[data-port-name="port_a"]');
pointer = await movePointerUntilPortsMeet(
page,
pointer,
copiedFreePort,
targetPort,
);
await expect.poll(() => distanceBetween(copiedFreePort, targetPort)).toBeLessThan(2);
await expect(copiedFreePort).toHaveAttribute("data-connection-state", "origin");
await expect(targetPort).toHaveAttribute("data-connection-state", "compatible");
await page.mouse.click(pointer.x, pointer.y);
await expect(firstPreview).not.toHaveClass(/pending-paste-node/);
await expect(secondPreview).not.toHaveClass(/pending-paste-node/);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(3);
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(2);
await expect(copiedFreePort).toHaveAttribute("data-connected", "true");
await expect(targetPort).toHaveAttribute("data-connected", "true");
});
test("正交连接线可拖动内部线段,且无连接交叉点显示跨线桥", async ({
page,
}) => {
const project = {
...structuredClone(wideProject),
nodes: [
projectNode("left", 0, 260),
projectNode("right", 700, 260),
projectNode("top", 350, 0),
projectNode("bottom", 350, 520),
],
edges: [
{
id: "edge-horizontal",
source: "left",
target: "right",
sourceHandle: "port_b",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 250, y: 360 },
{ x: 650, y: 360 },
],
},
},
{
id: "edge-vertical",
source: "top",
target: "bottom",
sourceHandle: "port_b",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 450, y: 150 },
{ x: 450, y: 550 },
],
},
},
],
};
await page.addInitScript((storedProject) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(storedProject),
);
}, project);
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const horizontalEdge = page.locator(
'.flow-canvas .react-flow__edge[data-id="edge-horizontal"]',
);
const verticalEdge = page.locator(
'.flow-canvas .react-flow__edge[data-id="edge-vertical"]',
);
await expect(horizontalEdge).toBeVisible();
await expect(verticalEdge).toBeVisible();
const horizontalPath = horizontalEdge.locator(".react-flow__edge-path");
await expect(horizontalPath).toHaveAttribute("d", / Q /);
await expect(verticalEdge.locator(".react-flow__edge-path")).not.toHaveAttribute(
"d",
/ Q /,
);
await horizontalEdge
.locator(".react-flow__edge-interaction")
.dispatchEvent("click");
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
const horizontalSegment = horizontalEdge
.locator(".manual-edge-segment-handle.horizontal")
.first();
const verticalSegment = horizontalEdge
.locator(".manual-edge-segment-handle.vertical")
.first();
await expect(horizontalSegment).toHaveCount(1);
await expect(verticalSegment).toHaveCount(1);
await expect(horizontalSegment).toHaveCSS("pointer-events", "stroke");
await expect(verticalSegment).toHaveCSS("pointer-events", "stroke");
const horizontalDragPoint = await horizontalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取水平连接线段坐标");
}
const point = new DOMPoint(
line.x1.baseVal.value * 0.75 + line.x2.baseVal.value * 0.25,
line.y1.baseVal.value * 0.75 + line.y2.baseVal.value * 0.25,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
},
);
const pathBeforeHorizontalDrag = await horizontalPath.getAttribute("d");
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y);
await page.mouse.down();
await page.mouse.move(horizontalDragPoint.x, horizontalDragPoint.y + 36, {
steps: 6,
});
await page.mouse.up();
await expect(horizontalPath).not.toHaveAttribute(
"d",
pathBeforeHorizontalDrag ?? "",
);
await expect(horizontalPath).toHaveAttribute("d", / Q /);
const verticalDragPoint = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取竖直连接线段坐标");
}
const point = new DOMPoint(
(line.x1.baseVal.value + line.x2.baseVal.value) / 2,
(line.y1.baseVal.value + line.y2.baseVal.value) / 2,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
},
);
const pathBeforeVerticalDrag = await horizontalPath.getAttribute("d");
await page.mouse.move(verticalDragPoint.x, verticalDragPoint.y);
await page.mouse.down();
await page.mouse.move(verticalDragPoint.x + 28, verticalDragPoint.y, {
steps: 6,
});
await page.mouse.up();
await expect(horizontalPath).not.toHaveAttribute(
"d",
pathBeforeVerticalDrag ?? "",
);
const cornerHandle = horizontalEdge.locator(
'.manual-edge-corner-handle[data-corner-index="2"]',
);
await expect(cornerHandle).toHaveCount(1);
const cornerBefore = await cornerHandle.evaluate(
(circle: SVGCircleElement) => ({
flowX: circle.cx.baseVal.value,
flowY: circle.cy.baseVal.value,
screen: (() => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取连接线拐点坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
})(),
}),
);
const pathBeforeCornerDrag = await horizontalPath.getAttribute("d");
await page.mouse.move(cornerBefore.screen.x, cornerBefore.screen.y);
await page.mouse.down();
await page.mouse.move(
cornerBefore.screen.x + 32,
cornerBefore.screen.y + 24,
{ steps: 6 },
);
await page.mouse.up();
await expect(horizontalPath).not.toHaveAttribute(
"d",
pathBeforeCornerDrag ?? "",
);
const cornerAfter = await cornerHandle.evaluate(
(circle: SVGCircleElement) => ({
flowX: circle.cx.baseVal.value,
flowY: circle.cy.baseVal.value,
}),
);
expect(Math.abs(cornerAfter.flowX - cornerBefore.flowX)).toBeGreaterThan(1);
expect(Math.abs(cornerAfter.flowY - cornerBefore.flowY)).toBeGreaterThan(1);
await page.keyboard.press("Control+z");
await expect(horizontalPath).toHaveAttribute(
"d",
pathBeforeCornerDrag ?? "",
);
await horizontalEdge
.locator(".react-flow__edge-interaction")
.dispatchEvent("click");
await expect(horizontalEdge).toHaveClass(/selected|editor-edge-selected/);
const boundaryCorner = horizontalEdge.locator(
'.manual-edge-corner-handle[data-corner-index="1"]',
);
await expect(boundaryCorner).toHaveCount(1);
const boundaryStart = await boundaryCorner.evaluate(
(circle: SVGCircleElement) => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取端点相邻拐点坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
},
);
const boundaryPathLocator = horizontalEdge.locator(".react-flow__edge-path");
const boundaryPathBefore = await boundaryPathLocator.getAttribute("d");
const boundaryTarget = {
x: boundaryStart.x + 38,
y: boundaryStart.y + 30,
};
await page.mouse.move(boundaryStart.x, boundaryStart.y);
await page.mouse.down();
await page.mouse.move(
(boundaryStart.x + boundaryTarget.x) / 2,
(boundaryStart.y + boundaryTarget.y) / 2,
{ steps: 4 },
);
await page.mouse.move(boundaryTarget.x, boundaryTarget.y, { steps: 4 });
await page.mouse.up();
await expect(boundaryPathLocator).not.toHaveAttribute(
"d",
boundaryPathBefore ?? "",
);
await expect(boundaryPathLocator).toHaveAttribute("d", / Q /);
const cornerScreenPoints = await horizontalEdge
.locator(".manual-edge-corner-handle")
.evaluateAll((circles: SVGCircleElement[]) =>
circles.map((circle) => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取调整后的拐点坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
}),
);
const closestCornerDistance = Math.min(
...cornerScreenPoints.map((point) =>
Math.hypot(point.x - boundaryTarget.x, point.y - boundaryTarget.y),
),
);
expect(
closestCornerDistance,
JSON.stringify({ boundaryStart, boundaryTarget, cornerScreenPoints }),
).toBeLessThan(3);
const boundaryPathAfter = await boundaryPathLocator.getAttribute("d");
const linePoints = [
...(boundaryPathAfter ?? "").matchAll(
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
),
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
expect(linePoints.length).toBeGreaterThan(2);
linePoints.slice(1).forEach((point, index) => {
const previous = linePoints[index];
expect(
Math.abs(point.x - previous.x) < 0.01 ||
Math.abs(point.y - previous.y) < 0.01,
).toBe(true);
});
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const savedRoute = await page.evaluate((projectKey) => {
const raw = window.localStorage.getItem(projectKey);
const saved = raw ? JSON.parse(raw) : null;
return saved?.edges?.find(
(edge: { id?: string }) => edge.id === "edge-horizontal",
)?.data?.routePoints ?? null;
}, PROJECT_KEY);
expect(savedRoute).not.toEqual(project.edges[0].data.routePoints);
expect(savedRoute?.length).toBeGreaterThan(1);
});
test("直线首次折弯后即使原手柄卸载,拖动仍连续且保持正交", async ({
page,
}) => {
const project = {
...structuredClone(wideProject),
nodes: [
projectNode("straight-source", 0, 260),
projectNode("straight-target", 700, 260),
],
edges: [
{
id: "straight-edge",
source: "straight-source",
target: "straight-target",
sourceHandle: "port_b",
targetHandle: "port_a",
data: { isContactEdge: false },
},
],
};
await page.addInitScript((storedProject) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(storedProject),
);
}, project);
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const edge = page.locator(
'.flow-canvas .react-flow__edge[data-id="straight-edge"]',
);
const edgePath = edge.locator(".react-flow__edge-path");
await edge.locator(".react-flow__edge-interaction").dispatchEvent("click");
const routeHandle = edge.locator(".manual-edge-route-handle");
await expect(routeHandle).toHaveCount(1);
const dragStart = await routeHandle.evaluate((circle: SVGCircleElement) => {
const matrix = circle.getScreenCTM();
if (!matrix) {
throw new Error("无法获取直线路由手柄坐标");
}
const point = new DOMPoint(
circle.cx.baseVal.value,
circle.cy.baseVal.value,
).matrixTransform(matrix);
return { x: point.x, y: point.y };
});
const pathBefore = await edgePath.getAttribute("d");
await page.mouse.move(dragStart.x, dragStart.y);
await page.mouse.down();
await page.mouse.move(dragStart.x + 18, dragStart.y + 21, { steps: 4 });
await expect(routeHandle).toHaveCount(0);
const verticalSegment = edge
.locator(".manual-edge-segment-handle.vertical")
.first();
await expect(verticalSegment).toHaveCount(1);
const segmentXAtHalfMove = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取首次折弯后的竖直线段坐标");
}
return new DOMPoint(
line.x1.baseVal.value,
line.y1.baseVal.value,
).matrixTransform(matrix).x;
},
);
await page.mouse.move(dragStart.x + 36, dragStart.y + 42, { steps: 4 });
const segmentXAtFullMove = await verticalSegment.evaluate(
(line: SVGLineElement) => {
const matrix = line.getScreenCTM();
if (!matrix) {
throw new Error("无法获取连续拖动后的竖直线段坐标");
}
return new DOMPoint(
line.x1.baseVal.value,
line.y1.baseVal.value,
).matrixTransform(matrix).x;
},
);
await page.mouse.up();
expect(Math.abs(segmentXAtFullMove - segmentXAtHalfMove)).toBeGreaterThan(10);
await expect(edgePath).not.toHaveAttribute("d", pathBefore ?? "");
await expect(
edge.locator(".manual-edge-segment-handle.horizontal").first(),
).toHaveCount(1);
const routeAfter = await edgePath.getAttribute("d");
const routePoints = [
...(routeAfter ?? "").matchAll(
/[ML]\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g,
),
].map((match) => ({ x: Number(match[1]), y: Number(match[2]) }));
routePoints.slice(1).forEach((point, index) => {
const previous = routePoints[index];
expect(
Math.abs(point.x - previous.x) < 0.01 ||
Math.abs(point.y - previous.y) < 0.01,
).toBe(true);
});
});
test("同一组件不同端口发出的线路在远端交叉时仍显示跨线桥", async ({
page,
}) => {
const project = {
...structuredClone(wideProject),
nodes: [
projectNode("shared-source", 0, 260),
projectNode("right-target", 700, 260),
projectNode("bottom-target", 350, 520),
],
edges: [
{
id: "shared-horizontal",
source: "shared-source",
target: "right-target",
sourceHandle: "port_b",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 250, y: 360 },
{ x: 650, y: 360 },
],
},
},
{
id: "shared-vertical",
source: "shared-source",
target: "bottom-target",
sourceHandle: "port_a",
targetHandle: "port_a",
data: {
isContactEdge: false,
routePoints: [
{ x: 250, y: 360 },
{ x: 250, y: 180 },
{ x: 450, y: 180 },
{ x: 450, y: 540 },
{ x: 350, y: 540 },
],
},
},
],
};
await page.addInitScript((storedProject) => {
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(storedProject),
);
}, project);
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
const horizontalPath = page.locator(
'.flow-canvas .react-flow__edge[data-id="shared-horizontal"] .react-flow__edge-path',
);
const verticalPath = page.locator(
'.flow-canvas .react-flow__edge[data-id="shared-vertical"] .react-flow__edge-path',
);
await expect(horizontalPath).toBeVisible();
await expect(horizontalPath).toHaveAttribute("d", / Q /);
await expect(verticalPath).not.toHaveAttribute("d", / Q /);
});
+268
View File
@@ -1,4 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
expectAllNodesInsideCanvas,
prepareApp,
@@ -6,6 +8,35 @@ import {
wideProject,
} from "./fixtures";
type ImportedProjectEdge = {
id: string;
source: string;
target: string;
sourceHandle: string;
targetHandle: string;
data?: { isContactEdge?: boolean };
};
type ImportedProject = {
nodes: Array<{ id: string }>;
edges: ImportedProjectEdge[];
};
const MQL_8_PROJECT_PATHS = [
new URL("../../../tests/data/test-mql-8%20.json", import.meta.url),
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
].map(fileURLToPath);
const MQL_8_PROJECT_PATH =
MQL_8_PROJECT_PATHS.find(existsSync) ?? MQL_8_PROJECT_PATHS[0];
const mql8Project = JSON.parse(
readFileSync(MQL_8_PROJECT_PATH, "utf8"),
) as ImportedProject;
const mql8ResultSnapshot = {
...resultSnapshot,
id: "e2e-mql-8-result-system-bridges",
project: mql8Project as unknown as typeof resultSnapshot.project,
};
async function readViewport(page: Page, canvasSelector: string) {
return page
.locator(`${canvasSelector} .react-flow__viewport`)
@@ -26,6 +57,110 @@ async function readModelingViewport(page: Page) {
return readViewport(page, ".flow-canvas");
}
async function readVisibleEdgeEndpointAlignment(
page: Page,
edges: ImportedProjectEdge[],
) {
return page.evaluate((projectEdges) => {
const nodeElements = new Map(
Array.from(
document.querySelectorAll<HTMLElement>(
".flow-canvas .react-flow__node[data-id]",
),
).flatMap((element) =>
element.dataset.id ? [[element.dataset.id, element] as const] : [],
),
);
const edgeElements = new Map(
Array.from(
document.querySelectorAll<SVGGElement>(
".flow-canvas .react-flow__edge[data-id]",
),
).flatMap((element) =>
element.dataset.id ? [[element.dataset.id, element] as const] : [],
),
);
const measurements: Array<{
edgeId: string;
sourceDistance: number;
targetDistance: number;
}> = [];
const missingHandles: string[] = [];
for (const edge of projectEdges) {
if (edge.data?.isContactEdge === true) {
continue;
}
const path = edgeElements
.get(edge.id)
?.querySelector<SVGPathElement>(".react-flow__edge-path");
if (!path) {
// A geometrically touching edge is intentionally rendered without a path.
continue;
}
const sourceHandle = Array.from(
nodeElements
.get(edge.source)
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
).find((handle) => handle.dataset.portName === edge.sourceHandle);
const targetHandle = Array.from(
nodeElements
.get(edge.target)
?.querySelectorAll<HTMLElement>(".port-handle[data-port-name]") ?? [],
).find((handle) => handle.dataset.portName === edge.targetHandle);
const screenMatrix = path.getScreenCTM();
if (!sourceHandle || !targetHandle || !screenMatrix) {
missingHandles.push(edge.id);
continue;
}
const pathLength = path.getTotalLength();
const sourcePoint = path.getPointAtLength(0).matrixTransform(screenMatrix);
const targetPoint = path
.getPointAtLength(pathLength)
.matrixTransform(screenMatrix);
const sourceBounds = sourceHandle.getBoundingClientRect();
const targetBounds = targetHandle.getBoundingClientRect();
const sourceCenter = {
x: sourceBounds.left + sourceBounds.width / 2,
y: sourceBounds.top + sourceBounds.height / 2,
};
const targetCenter = {
x: targetBounds.left + targetBounds.width / 2,
y: targetBounds.top + targetBounds.height / 2,
};
measurements.push({
edgeId: edge.id,
sourceDistance: Math.hypot(
sourcePoint.x - sourceCenter.x,
sourcePoint.y - sourceCenter.y,
),
targetDistance: Math.hypot(
targetPoint.x - targetCenter.x,
targetPoint.y - targetCenter.y,
),
});
}
const worst = measurements
.map((measurement) => ({
...measurement,
maxDistance: Math.max(
measurement.sourceDistance,
measurement.targetDistance,
),
}))
.sort((first, second) => second.maxDistance - first.maxDistance)
.slice(0, 8);
return {
checkedEdgeCount: measurements.length,
maxDistance: worst[0]?.maxDistance ?? Number.POSITIVE_INFINITY,
missingHandles,
worst,
};
}, edges);
}
test("拖入组件不自动适配画布,手动适配按钮仍然生效", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
@@ -225,6 +360,50 @@ test("导入工程 JSON 后自动适配建模画布", async ({ page }) => {
await expectAllNodesInsideCanvas(page, ".flow-canvas");
});
test("导入大型工程 JSON 后非接触连线端点与端口保持对齐", async ({
page,
}) => {
await prepareApp(page);
await page.goto("/");
await page.locator('input[type="file"]').setInputFiles(MQL_8_PROJECT_PATH);
await expectAllNodesInsideCanvas(
page,
".flow-canvas",
mql8Project.nodes.length,
);
await expect
.poll(
async () =>
(
await readVisibleEdgeEndpointAlignment(page, mql8Project.edges)
).checkedEdgeCount,
{ timeout: 15_000 },
)
.toBeGreaterThan(20);
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
const alignment = await readVisibleEdgeEndpointAlignment(
page,
mql8Project.edges,
);
expect(alignment.missingHandles).toEqual([]);
expect(alignment.checkedEdgeCount).toBeGreaterThan(20);
expect(
alignment.maxDistance,
`偏移最大的连线端点:${JSON.stringify(alignment.worst, null, 2)}`,
// React Flow anchors to the outer edge of the 6 px port glyph rather
// than its visual center; allow that radius plus subpixel rounding.
).toBeLessThanOrEqual(4);
});
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
await prepareApp(page);
await page.addInitScript((project) => {
@@ -434,6 +613,95 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
}
});
test("结果页系统图为真实大型工程的非连接交叉线显示线桥", async ({
page,
}) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
}, mql8ResultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(
page.locator(".results-system-canvas .react-flow__node"),
).toHaveCount(mql8Project.nodes.length);
await expect(
page.locator(
'.results-system-canvas .react-flow__edge[data-id="edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258"] .react-flow__edge-path',
),
).toHaveAttribute("d", / Q /);
});
test("从结果页返回建模页后保留用户调整的建模视口", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
"system-simulation-flow:latest-result",
JSON.stringify(snapshot),
);
window.localStorage.setItem(
"system-simulation-flow:project:demo-system",
JSON.stringify(project),
);
}, { project: wideProject, snapshot: resultSnapshot });
await page.goto("/");
await page.getByRole("button", { name: "加载工程" }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas");
const modelingPane = page.locator(".flow-canvas .react-flow__pane");
const modelingPaneBox = await modelingPane.boundingBox();
expect(modelingPaneBox).not.toBeNull();
const fittedViewport = await readModelingViewport(page);
const pointer = {
x: modelingPaneBox!.x + modelingPaneBox!.width * 0.55,
y: modelingPaneBox!.y + modelingPaneBox!.height * 0.45,
};
await page.mouse.move(pointer.x, pointer.y);
await page.mouse.down({ button: "middle" });
await page.mouse.move(pointer.x + 180, pointer.y + 90, { steps: 5 });
await page.mouse.up({ button: "middle" });
await expect
.poll(async () => {
const viewport = await readModelingViewport(page);
return Math.hypot(
viewport.x - fittedViewport.x,
viewport.y - fittedViewport.y,
);
})
.toBeGreaterThan(100);
const beforeZoom = await readModelingViewport(page);
await page.keyboard.down("Control");
await page.mouse.wheel(0, -180);
await page.keyboard.up("Control");
await expect
.poll(async () => (await readModelingViewport(page)).zoom)
.not.toBeCloseTo(beforeZoom.zoom, 2);
const adjustedViewport = await readModelingViewport(page);
await page.getByRole("tab", { name: /^结果/ }).click();
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
await page.getByRole("tab", { name: "建模" }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(
wideProject.nodes.length,
);
await page.waitForTimeout(250);
const restoredViewport = await readModelingViewport(page);
expect(
Math.hypot(
restoredViewport.x - adjustedViewport.x,
restoredViewport.y - adjustedViewport.y,
),
).toBeLessThan(2);
expect(restoredViewport.zoom).toBeCloseTo(adjustedViewport.zoom, 3);
});
test("结果页专用图标只按实际包络命中被相邻节点包围的元件", async ({
page,
}) => {
@@ -1,4 +1,4 @@
import { expect, test, type Page } from "@playwright/test";
import { expect, test, type Locator, type Page } from "@playwright/test";
import { prepareApp, resultSnapshot } from "./fixtures";
@@ -39,6 +39,86 @@ async function openResults(page: Page) {
await expect(page.locator(".results-chart-workspace")).toBeVisible();
}
async function readChartDomain(chart: Locator) {
return chart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
yMin: Number(element.getAttribute("data-view-y-min")),
yMax: Number(element.getAttribute("data-view-y-max")),
}));
}
async function dragChartSelection(
page: Page,
chart: Locator,
start: { x: number; y: number },
end: { x: number; y: number },
) {
const bounds = await chart.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(
bounds!.x + bounds!.width * start.x,
bounds!.y + bounds!.height * start.y,
);
await page.mouse.down();
await page.mouse.move(
bounds!.x + bounds!.width * end.x,
bounds!.y + bounds!.height * end.y,
{ steps: 5 },
);
await page.mouse.up();
}
async function wheelAtLocator(
page: Page,
target: Locator,
deltaY = -240,
position = { x: 0.5, y: 0.5 },
) {
const bounds = await target.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(
bounds!.x + bounds!.width * position.x,
bounds!.y + bounds!.height * position.y,
);
await page.mouse.wheel(0, deltaY);
}
async function dragMiddleAtLocator(
page: Page,
target: Locator,
delta = { x: 0.08, y: 0.08 },
) {
const bounds = await target.boundingBox();
expect(bounds).not.toBeNull();
const startX = bounds!.x + bounds!.width * 0.5;
const startY = bounds!.y + bounds!.height * 0.5;
await page.mouse.move(startX, startY);
await page.mouse.down({ button: "middle" });
await page.mouse.move(
startX + bounds!.width * delta.x,
startY + bounds!.height * delta.y,
{ steps: 5 },
);
await page.mouse.up({ button: "middle" });
}
async function readFlowViewport(page: Page) {
return page
.locator(".results-system-canvas .react-flow__viewport")
.evaluate((viewport) => {
const values =
getComputedStyle(viewport)
.transform.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)
?.map(Number) ?? [];
return {
x: values[4] ?? 0,
y: values[5] ?? 0,
zoom: values[0] ?? 1,
};
});
}
test.beforeEach(async ({ page }) => {
await prepareApp(page);
await page.addInitScript((snapshot) => {
@@ -155,6 +235,553 @@ test("a pending window recovered from stale variable keys accepts a new first cu
);
await expect(pendingWindow).toHaveAttribute("data-pending", "true");
await page.locator(".results-variable-list button").first().dragTo(pendingWindow);
await page
.locator(".results-variable-list button")
.first()
.dragTo(pendingWindow);
await expect(pendingWindow).toHaveAttribute("data-pending", "false");
});
test("single curve supports box zoom, original-size restore, and persisted interaction mode", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
await page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first()
.click();
let chartWindow = page.locator(
'.result-chart-window[data-chart-kind="single"]',
);
let chart = chartWindow.locator('svg[data-result-chart="true"]');
await expect(chart).toBeVisible();
const initial = await readChartDomain(chart);
const zoomButton = chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
});
await zoomButton.click();
await expect(
chartWindow.getByRole("button", {
name: "关闭 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "true");
await dragChartSelection(
page,
chart,
{ x: 0.32, y: 0.3 },
{ x: 0.76, y: 0.72 },
);
await expect(chart).toHaveAttribute("data-zoomed", "true");
await expect(chart.locator(".result-chart-zoom-selection")).toHaveCount(0);
await expect(chart.locator('[data-chart-clipped-series="true"]')).toHaveCount(
1,
);
const zoomed = await readChartDomain(chart);
expect(zoomed.xMin).toBeGreaterThan(initial.xMin);
expect(zoomed.xMax).toBeLessThan(initial.xMax);
expect(zoomed.yMin).toBeGreaterThan(initial.yMin);
expect(zoomed.yMax).toBeLessThan(initial.yMax);
await expect
.poll(() =>
page.evaluate((snapshotId) => {
const raw = sessionStorage.getItem(
`system-simulation-flow:result-layout:${snapshotId}`,
);
const windows = raw ? JSON.parse(raw) : [];
return windows[0]?.viewport?.x?.start ?? 0;
}, chartWindowSnapshot.id),
)
.toBeGreaterThan(0);
await page.getByRole("tab", { name: "建模", exact: true }).click();
await openResults(page);
chartWindow = page.locator('.result-chart-window[data-chart-kind="single"]');
chart = chartWindow.locator('svg[data-result-chart="true"]');
await expect(chart).toHaveAttribute("data-zoomed", "true");
const restored = await readChartDomain(chart);
expect(restored.xMin).toBeCloseTo(zoomed.xMin, 8);
expect(restored.xMax).toBeCloseTo(zoomed.xMax, 8);
await expect(
chartWindow.getByRole("button", {
name: "关闭 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "true");
await chartWindow
.getByRole("button", { name: "打开 数值 曲线游标", exact: true })
.click();
await expect(
chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "false");
await chartWindow
.getByRole("button", {
name: "恢复 数值 原始尺寸",
exact: true,
})
.click();
await expect(chart).toHaveAttribute("data-zoomed", "false");
const undone = await readChartDomain(chart);
expect(undone.xMin).toBeCloseTo(initial.xMin, 8);
expect(undone.xMax).toBeCloseTo(initial.xMax, 8);
await chartWindow
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
.click();
await dragChartSelection(
page,
chart,
{ x: 0.3, y: 0.28 },
{ x: 0.7, y: 0.68 },
);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await expect(chart).toHaveAttribute("data-zoomed", "false");
});
test("combined curves share X zoom while stacked Y zoom stays in the selected band", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
const valueVariable = page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first();
const pressureVariable = page
.locator(".results-variable-list button")
.filter({ hasText: "压力" })
.first();
await page
.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true })
.click();
const multiWindow = page.locator(
'.result-chart-window[data-chart-kind="multi"]',
);
await valueVariable.dragTo(multiWindow);
const multiChart = multiWindow.locator('svg[data-result-chart="true"]');
await multiWindow
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
.click();
await dragChartSelection(
page,
multiChart,
{ x: 0.31, y: 0.34 },
{ x: 0.73, y: 0.72 },
);
await expect(multiChart).toHaveAttribute("data-zoomed", "true");
await expect(
multiChart.locator('[data-chart-clipped-series="true"] path'),
).toHaveCount(1);
const multiCursor = multiWindow.locator("button.cursor");
await multiCursor.click();
await expect(multiWindow.locator(".result-chart-cursor-panel")).toBeVisible();
await multiWindow
.getByRole("button", { name: "管理多曲线窗口中的曲线", exact: true })
.click();
const visibilityToggle = multiWindow.locator(
'.result-chart-multi-menu-row input[type="checkbox"]',
);
await visibilityToggle.click();
await expect(multiCursor).toBeDisabled();
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
await visibilityToggle.click();
await expect(multiCursor).toBeEnabled();
await expect(multiCursor).toHaveAttribute("aria-pressed", "false");
await expect(multiWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
await page
.getByRole("button", { name: "新建异单位上下对比窗口", exact: true })
.click();
const mixedWindow = page.locator(
'.result-chart-window[data-chart-kind="mixed"]',
);
await valueVariable.dragTo(mixedWindow);
await pressureVariable.dragTo(mixedWindow);
const mixedChart = mixedWindow.locator('svg[data-result-chart="true"]');
const beforeXRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
await mixedWindow
.getByRole("button", { name: "打开 多曲线 曲线缩放", exact: true })
.click();
await dragChartSelection(
page,
mixedChart,
{ x: 0.31, y: 0.13 },
{ x: 0.72, y: 0.42 },
);
await expect(mixedChart).toHaveAttribute("data-zoomed", "true");
const afterRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
expect(afterRanges.xMin).toBeGreaterThan(beforeXRanges.xMin);
expect(afterRanges.xMax).toBeLessThan(beforeXRanges.xMax);
expect(afterRanges.y["generic_sensor_1.value"]).not.toEqual(
beforeXRanges.y["generic_sensor_1.value"],
);
expect(afterRanges.y["generic_sensor_1.pressure"]).toEqual(
beforeXRanges.y["generic_sensor_1.pressure"],
);
const secondYAxis = mixedChart.locator('[data-chart-zoom-axis="y"]').nth(1);
await expect(secondYAxis).toBeVisible();
await wheelAtLocator(page, secondYAxis);
await expect
.poll(async () =>
mixedChart.evaluate((element) => {
const ranges = JSON.parse(
element.getAttribute("data-view-y-ranges") ?? "{}",
);
return JSON.stringify(ranges["generic_sensor_1.pressure"]);
}),
)
.not.toBe(JSON.stringify(afterRanges.y["generic_sensor_1.pressure"]));
const axisWheelRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
expect(axisWheelRanges.xMin).toBeCloseTo(afterRanges.xMin, 8);
expect(axisWheelRanges.xMax).toBeCloseTo(afterRanges.xMax, 8);
expect(axisWheelRanges.y["generic_sensor_1.value"]).toEqual(
afterRanges.y["generic_sensor_1.value"],
);
expect(axisWheelRanges.y["generic_sensor_1.pressure"]).not.toEqual(
afterRanges.y["generic_sensor_1.pressure"],
);
await dragMiddleAtLocator(page, secondYAxis, { x: 0, y: 0.08 });
const bandPannedRanges = await mixedChart.evaluate((element) => ({
xMin: Number(element.getAttribute("data-view-x-min")),
xMax: Number(element.getAttribute("data-view-x-max")),
y: JSON.parse(element.getAttribute("data-view-y-ranges") ?? "{}"),
}));
expect(bandPannedRanges.xMin).toBeCloseTo(axisWheelRanges.xMin, 8);
expect(bandPannedRanges.xMax).toBeCloseTo(axisWheelRanges.xMax, 8);
expect(bandPannedRanges.y["generic_sensor_1.value"]).toEqual(
axisWheelRanges.y["generic_sensor_1.value"],
);
const beforePressure = axisWheelRanges.y["generic_sensor_1.pressure"];
const afterPressure = bandPannedRanges.y["generic_sensor_1.pressure"];
expect(afterPressure[1] - afterPressure[0]).toBeCloseTo(
beforePressure[1] - beforePressure[0],
8,
);
expect(afterPressure[0]).toBeGreaterThan(beforePressure[0]);
await expect(
mixedChart.locator('[data-chart-clipped-series="true"]'),
).toHaveCount(2);
});
test("result system viewport survives modeling and results tab switches", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
const pane = page.locator(".results-system-canvas .react-flow__pane");
const paneBounds = await pane.boundingBox();
expect(paneBounds).not.toBeNull();
await page.mouse.move(
paneBounds!.x + paneBounds!.width * 0.55,
paneBounds!.y + paneBounds!.height * 0.5,
);
const fitted = await readFlowViewport(page);
await page.mouse.wheel(0, -260);
await expect
.poll(async () => (await readFlowViewport(page)).zoom)
.not.toBeCloseTo(fitted.zoom, 3);
const adjusted = await readFlowViewport(page);
await expect
.poll(() =>
page.evaluate((snapshotId) => {
const raw = sessionStorage.getItem(
`system-simulation-flow:result-system-viewport:${snapshotId}`,
);
return raw ? JSON.parse(raw).zoom : 0;
}, chartWindowSnapshot.id),
)
.toBeCloseTo(adjusted.zoom, 3);
await page.getByRole("tab", { name: "建模", exact: true }).click();
await openResults(page);
const restored = await readFlowViewport(page);
expect(restored.x).toBeCloseTo(adjusted.x, 1);
expect(restored.y).toBeCloseTo(adjusted.y, 1);
expect(restored.zoom).toBeCloseTo(adjusted.zoom, 3);
await page
.getByRole("button", { name: "适应系统图窗口", exact: true })
.click();
await expect
.poll(async () => (await readFlowViewport(page)).zoom)
.not.toBeCloseTo(restored.zoom, 3);
});
test("zoom wheel follows the pointer and axis hit areas while cursor keeps the viewport", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
await page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first()
.click();
const chartWindow = page.locator(
'.result-chart-window[data-chart-kind="single"]',
);
const chart = chartWindow.locator('svg[data-result-chart="true"]');
await expect(chart).toBeVisible();
const cursorButton = chartWindow.getByRole("button", {
name: "打开 数值 曲线游标",
exact: true,
});
await cursorButton.click();
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
const zoomButton = chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
});
await zoomButton.click();
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
const initial = await readChartDomain(chart);
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
await expect(xAxis).toBeVisible();
await expect(yAxis).toBeVisible();
const xAxisBounds = await xAxis.boundingBox();
const yAxisBounds = await yAxis.boundingBox();
expect(xAxisBounds).not.toBeNull();
expect(yAxisBounds).not.toBeNull();
const plotPointer = { x: 0.25, y: 0.35 };
const initialXAnchor =
initial.xMin + (initial.xMax - initial.xMin) * plotPointer.x;
const initialYAnchor =
initial.yMax - (initial.yMax - initial.yMin) * plotPointer.y;
await page.mouse.move(
xAxisBounds!.x + xAxisBounds!.width * plotPointer.x,
yAxisBounds!.y + yAxisBounds!.height * plotPointer.y,
);
await page.mouse.wheel(0, -240);
await expect
.poll(async () => {
const current = await readChartDomain(chart);
return current.xMax - current.xMin;
})
.toBeLessThan(initial.xMax - initial.xMin);
const plotZoomed = await readChartDomain(chart);
expect(plotZoomed.yMax - plotZoomed.yMin).toBeLessThan(
initial.yMax - initial.yMin,
);
const zoomedXAnchor =
plotZoomed.xMin + (plotZoomed.xMax - plotZoomed.xMin) * plotPointer.x;
const zoomedYAnchor =
plotZoomed.yMax - (plotZoomed.yMax - plotZoomed.yMin) * plotPointer.y;
expect(
Math.abs(zoomedXAnchor - initialXAnchor) / (initial.xMax - initial.xMin),
).toBeLessThan(0.002);
expect(
Math.abs(zoomedYAnchor - initialYAnchor) / (initial.yMax - initial.yMin),
).toBeLessThan(0.002);
await wheelAtLocator(page, xAxis);
await expect
.poll(async () => {
const current = await readChartDomain(chart);
return current.xMax - current.xMin;
})
.toBeLessThan(plotZoomed.xMax - plotZoomed.xMin);
const xOnlyZoomed = await readChartDomain(chart);
expect(xOnlyZoomed.yMin).toBeCloseTo(plotZoomed.yMin, 8);
expect(xOnlyZoomed.yMax).toBeCloseTo(plotZoomed.yMax, 8);
await wheelAtLocator(page, yAxis);
await expect
.poll(async () => {
const current = await readChartDomain(chart);
return current.yMax - current.yMin;
})
.toBeLessThan(xOnlyZoomed.yMax - xOnlyZoomed.yMin);
const yOnlyZoomed = await readChartDomain(chart);
expect(yOnlyZoomed.xMin).toBeCloseTo(xOnlyZoomed.xMin, 8);
expect(yOnlyZoomed.xMax).toBeCloseTo(xOnlyZoomed.xMax, 8);
const beforePlotPan = yOnlyZoomed;
await dragMiddleAtLocator(page, chart, { x: 0.05, y: 0.04 });
const plotPanned = await readChartDomain(chart);
expect(plotPanned.xMax - plotPanned.xMin).toBeCloseTo(
beforePlotPan.xMax - beforePlotPan.xMin,
8,
);
expect(plotPanned.yMax - plotPanned.yMin).toBeCloseTo(
beforePlotPan.yMax - beforePlotPan.yMin,
8,
);
expect(plotPanned.xMin).toBeLessThan(beforePlotPan.xMin);
expect(plotPanned.yMin).toBeGreaterThan(beforePlotPan.yMin);
const beforeXAxisPan = plotPanned;
await dragMiddleAtLocator(page, xAxis, { x: 0.06, y: 0 });
const xAxisPanned = await readChartDomain(chart);
expect(xAxisPanned.xMax - xAxisPanned.xMin).toBeCloseTo(
beforeXAxisPan.xMax - beforeXAxisPan.xMin,
8,
);
expect(xAxisPanned.xMin).toBeLessThan(beforeXAxisPan.xMin);
expect(xAxisPanned.yMin).toBeCloseTo(beforeXAxisPan.yMin, 8);
expect(xAxisPanned.yMax).toBeCloseTo(beforeXAxisPan.yMax, 8);
const beforeYAxisPan = xAxisPanned;
await dragMiddleAtLocator(page, yAxis, { x: 0, y: 0.06 });
const yAxisPanned = await readChartDomain(chart);
expect(yAxisPanned.yMax - yAxisPanned.yMin).toBeCloseTo(
beforeYAxisPan.yMax - beforeYAxisPan.yMin,
8,
);
expect(yAxisPanned.yMin).toBeGreaterThan(beforeYAxisPan.yMin);
expect(yAxisPanned.xMin).toBeCloseTo(beforeYAxisPan.xMin, 8);
expect(yAxisPanned.xMax).toBeCloseTo(beforeYAxisPan.xMax, 8);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await page.mouse.move(
xAxisBounds!.x + xAxisBounds!.width * 0.5,
yAxisBounds!.y + yAxisBounds!.height * 0.5,
);
await page.mouse.wheel(0, -180);
await expect(chart).toHaveAttribute("data-zoomed", "true");
const beforeCursor = await readChartDomain(chart);
await cursorButton.click();
await expect(
chartWindow.getByRole("button", {
name: "打开 数值 曲线缩放",
exact: true,
}),
).toHaveAttribute("aria-pressed", "false");
await expect(chartWindow.locator(".result-chart-cursor-panel")).toBeVisible();
const withCursor = await readChartDomain(chart);
expect(withCursor).toEqual(beforeCursor);
await chartWindow
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
.click();
await expect(chartWindow.locator(".result-chart-cursor-panel")).toHaveCount(
0,
);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await page.mouse.move(
xAxisBounds!.x + xAxisBounds!.width * 0.5,
yAxisBounds!.y + yAxisBounds!.height * 0.5,
);
await page.mouse.wheel(0, 900);
await expect
.poll(async () => (await readChartDomain(chart)).xMin)
.toBeLessThan(initial.xMin);
const expandedDomain = await readChartDomain(chart);
expect(expandedDomain.xMax).toBeGreaterThan(initial.xMax);
expect(expandedDomain.yMin).toBeLessThan(initial.yMin);
expect(expandedDomain.yMax).toBeGreaterThan(initial.yMax);
const outline = await chart.evaluate((element) => {
element.focus();
return getComputedStyle(element).outlineStyle;
});
expect(outline).toBe("none");
await expect
.poll(() =>
page.evaluate((snapshotId) => {
const raw = sessionStorage.getItem(
`system-simulation-flow:result-layout:${snapshotId}`,
);
const windows = raw ? JSON.parse(raw) : [];
const single = windows.find(
(item: { kind?: string }) => item.kind === "single",
);
return single?.viewport?.x?.start ?? 0;
}, chartWindowSnapshot.id),
)
.toBeLessThan(0);
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await expect(chart).toHaveAttribute("data-zoomed", "false");
const restoredDomain = await readChartDomain(chart);
expect(restoredDomain).toEqual(initial);
});
test("cursor is unavailable when a zoomed viewport contains no curve", async ({
page,
}) => {
await page.goto("/");
await openResults(page);
await page
.locator(".results-variable-list button")
.filter({ hasText: "数值" })
.first()
.click();
const chartWindow = page.locator(
'.result-chart-window[data-chart-kind="single"]',
);
const chart = chartWindow.locator('svg[data-result-chart="true"]');
await chartWindow
.getByRole("button", { name: "打开 数值 曲线缩放", exact: true })
.click();
const xAxis = chart.locator('[data-chart-zoom-axis="x"]');
const yAxis = chart.locator('[data-chart-zoom-axis="y"]');
const xBounds = await xAxis.boundingBox();
const yBounds = await yAxis.boundingBox();
expect(xBounds).not.toBeNull();
expect(yBounds).not.toBeNull();
await page.mouse.move(
xBounds!.x + xBounds!.width * 0.08,
yBounds!.y + yBounds!.height * 0.08,
);
await page.mouse.down();
await page.mouse.move(
xBounds!.x + xBounds!.width * 0.32,
yBounds!.y + yBounds!.height * 0.28,
{ steps: 5 },
);
await page.mouse.up();
await expect(chart).toHaveAttribute("data-zoomed", "true");
const cursorButton = chartWindow.locator("button.cursor");
await expect(cursorButton).toBeDisabled();
await chartWindow
.getByRole("button", { name: "恢复 数值 原始尺寸", exact: true })
.click();
await expect(cursorButton).toBeEnabled();
await expect(cursorButton).toHaveAttribute("aria-pressed", "false");
});
+4
View File
@@ -1,4 +1,8 @@
# Supported dependency ranges. Reproducible Python 3.12 installs should also
# apply constraints/python312-direct.txt; see README.md.
fastapi
lxml>=5,<7
numpy>=1.26,<3
pydantic>=2,<3
scipy>=1.13,<2
uvicorn[standard]
@@ -0,0 +1,592 @@
{
"schemaVersion": 1,
"id": "test_mql_8-production-0.2s-physical-state-v2",
"caseId": "0.2s",
"lane": "production",
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
"approval": {
"status": "approved",
"approvedAt": "2026-08-17T14:11:10.904892+00:00",
"basis": "Initial P0 bounded production baseline passed finite-series, checkpoint, signal-segment, mechanical-event, residual, audit, and fallback gates."
},
"provenance": {
"sourceReport": {
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json",
"sha256": "93baac975413703bebe940308ccd0de5dbd753f7c1c4fe015907315f112618a5",
"generatedAt": "2026-08-17T14:11:10.904892+00:00",
"metadataCompatibility": {
"status": "current",
"differences": []
}
}
},
"physicalLayout": {
"projectionCategories": [
"state"
],
"stateKeys": [
"amesim_mecmas21_1.a",
"amesim_mecmas21_1.v",
"amesim_mecmas21_1.x",
"amesim_mecmas21_10.a",
"amesim_mecmas21_10.v",
"amesim_mecmas21_10.x",
"amesim_mecmas21_2.a",
"amesim_mecmas21_2.v",
"amesim_mecmas21_2.x",
"amesim_mecmas21_3.a",
"amesim_mecmas21_3.v",
"amesim_mecmas21_3.x",
"amesim_mecmas21_4.a",
"amesim_mecmas21_4.v",
"amesim_mecmas21_4.x",
"amesim_mecmas21_5.a",
"amesim_mecmas21_5.v",
"amesim_mecmas21_5.x",
"amesim_mecmas21_6.a",
"amesim_mecmas21_6.v",
"amesim_mecmas21_6.x",
"amesim_mecmas21_7.a",
"amesim_mecmas21_7.v",
"amesim_mecmas21_7.x",
"amesim_mecmas21_8.a",
"amesim_mecmas21_8.v",
"amesim_mecmas21_8.x",
"amesim_mecmas21_9.a",
"amesim_mecmas21_9.v",
"amesim_mecmas21_9.x",
"amesim_pnch012_10.U",
"amesim_pnch012_10.m",
"amesim_pnch012_11.U",
"amesim_pnch012_11.m",
"amesim_pnch012_12.U",
"amesim_pnch012_12.m",
"amesim_pnch012_13.U",
"amesim_pnch012_13.m",
"amesim_pnch012_14.U",
"amesim_pnch012_14.m",
"amesim_pnch012_15.U",
"amesim_pnch012_15.m",
"amesim_pnch012_8.U",
"amesim_pnch012_8.m",
"amesim_pnch012_9.U",
"amesim_pnch012_9.m",
"amesim_pnch023_1.U",
"amesim_pnch023_1.m",
"amesim_pnch023_2.U",
"amesim_pnch023_2.m",
"amesim_pnch023_3.U",
"amesim_pnch023_3.m",
"amesim_pnch023_4.U",
"amesim_pnch023_4.m",
"amesim_pnl0001_1.U",
"amesim_pnl0001_1.m",
"amesim_pnl0001_10.U",
"amesim_pnl0001_10.m",
"amesim_pnl0001_11.U",
"amesim_pnl0001_11.m",
"amesim_pnl0001_13.U",
"amesim_pnl0001_13.m",
"amesim_pnl0001_14.U",
"amesim_pnl0001_14.m",
"amesim_pnl0001_15.U",
"amesim_pnl0001_15.m",
"amesim_pnl0001_16.U",
"amesim_pnl0001_16.m",
"amesim_pnl0001_17.U",
"amesim_pnl0001_17.m",
"amesim_pnl0001_18.U",
"amesim_pnl0001_18.m",
"amesim_pnl0001_19.U",
"amesim_pnl0001_19.m",
"amesim_pnl0001_2.U",
"amesim_pnl0001_2.m",
"amesim_pnl0001_20.U",
"amesim_pnl0001_20.m",
"amesim_pnl0001_4.U",
"amesim_pnl0001_4.m",
"amesim_pnl0001_5.U",
"amesim_pnl0001_5.m",
"amesim_pnl0001_7.U",
"amesim_pnl0001_7.m",
"amesim_pnl0001_9.U",
"amesim_pnl0001_9.m",
"amesim_pnl0002_1.U",
"amesim_pnl0002_1.m",
"amesim_pnl0002_2.U",
"amesim_pnl0002_2.m",
"amesim_pnl0002_3.U",
"amesim_pnl0002_3.m",
"amesim_pnl0002_4.U",
"amesim_pnl0002_4.m",
"amesim_pnl0002_5.U",
"amesim_pnl0002_5.m",
"amesim_pnl0002_6.U",
"amesim_pnl0002_6.m",
"amesim_pnl0002_7.U",
"amesim_pnl0002_7.m",
"amesim_pnl0002_8.U",
"amesim_pnl0002_8.m",
"amesim_pnl0003_1.U1",
"amesim_pnl0003_1.U2",
"amesim_pnl0003_1.m1",
"amesim_pnl0003_1.m2",
"amesim_pnl0003_2.U1",
"amesim_pnl0003_2.U2",
"amesim_pnl0003_2.m1",
"amesim_pnl0003_2.m2",
"amesim_pnl0003_3.U1",
"amesim_pnl0003_3.U2",
"amesim_pnl0003_3.m1",
"amesim_pnl0003_3.m2",
"amesim_pnl0003_4.U1",
"amesim_pnl0003_4.U2",
"amesim_pnl0003_4.m1",
"amesim_pnl0003_4.m2",
"amesim_pnl0003_5.U1",
"amesim_pnl0003_5.U2",
"amesim_pnl0003_5.m1",
"amesim_pnl0003_5.m2",
"amesim_pnl0003_6.U1",
"amesim_pnl0003_6.U2",
"amesim_pnl0003_6.m1",
"amesim_pnl0003_6.m2",
"amesim_pnl0003_7.U1",
"amesim_pnl0003_7.U2",
"amesim_pnl0003_7.m1",
"amesim_pnl0003_7.m2",
"amesim_pnl0003_8.U1",
"amesim_pnl0003_8.U2",
"amesim_pnl0003_8.m1",
"amesim_pnl0003_8.m2"
],
"stateKeyLayoutSha256": "12e216303d33639a5f53ef83b6b9fc5851beffb1222a0ed4bb5e1a2432b3d739"
},
"tolerance": {
"relative": 0.0002,
"absolute": 1e-09,
"checkpointTimeAbsoluteSeconds": 1e-12
},
"physicalCheckpoints": [
{
"requestedTime": 0.0,
"values": [
-0.8167936695810981,
0.0,
0.0,
0.0,
0.0,
0.0,
-0.8167936695810981,
0.0,
0.0,
-0.8167936695810981,
0.0,
0.0,
-0.8167936695810981,
0.0,
0.0,
-0.8167936695810981,
0.0,
0.0,
-0.8167936695810981,
0.0,
0.0,
-0.8167936695810981,
0.0,
0.0,
-0.8167936695810981,
0.0,
0.0,
0.0,
0.0,
0.0,
-1563.893548405841,
0.002462855848963059,
-1563.893548405841,
0.002462855848963059,
-1563.893548405841,
0.002462855848963059,
-1563.893548405841,
0.002462855848963059,
-1563.893548405841,
0.002462855848963059,
-1563.893548405841,
0.002462855848963059,
-1563.893548405841,
0.002462855848963059,
-1563.893548405841,
0.002462855848963059,
-886868.3937230138,
1.374510850335198,
-886868.3937230138,
1.374510850335198,
-886868.3937230138,
1.374510850335198,
-886868.3937230138,
1.374510850335198,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-32.754109884455104,
5.158193227968668e-05,
-32.754109884455104,
5.158193227968668e-05,
-32.754109884455104,
5.158193227968668e-05,
-32.754109884455104,
5.158193227968668e-05,
-32.754109884455104,
5.158193227968668e-05,
-32.754109884455104,
5.158193227968668e-05,
-32.754109884455104,
5.158193227968668e-05,
-2395.136531414716,
0.0037120966016654976,
-32.754109884455104,
5.158193227968668e-05,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-65.50821976891021,
0.00010316386455937335,
-65.50821976891021,
0.00010316386455937335,
-65.50821976891021,
0.00010316386455937335,
-65.50821976891021,
0.00010316386455937335,
-65.50821976891021,
0.00010316386455937335,
-65.50821976891021,
0.00010316386455937335,
-65.50821976891021,
0.00010316386455937335,
-65.50821976891021,
0.00010316386455937335,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627
]
},
{
"requestedTime": 0.04,
"values": [
-0.7201778435512728,
-0.03131260841232088,
-0.0006393186923261844,
0.0,
6.421450189245049e-22,
0.0,
-0.7201733833792814,
-0.031312355811718325,
-0.0006393136520185212,
-0.7201718196391171,
-0.03131233882672457,
-0.0006393140197282858,
-0.7201722508603697,
-0.031312278047383596,
-0.0006393124896720936,
-0.7201744791266093,
-0.03131218496519997,
-0.0006393087827851101,
-0.7201783488656359,
-0.031314094334835896,
-0.0006393469119830168,
-0.7201775030345068,
-0.031312389100822,
-0.0006393118191591414,
-0.7201771803079525,
-0.031312911030261764,
-0.0006393266351878673,
0.0,
0.0,
1.120876769981429e-26,
-1563.233367712832,
0.0024627146762544114,
-1563.2343003288415,
0.0024627154840700283,
-1563.2343310045646,
0.002462715544028645,
-1563.234317887853,
0.002462715540851028,
-1563.2342832229026,
0.0024627154834822402,
-1563.234128076657,
0.0024627152649548067,
-1563.2340241256454,
0.0024627151889310217,
-1563.234154194445,
0.0024627153106296135,
-886868.3937230138,
1.374510850335198,
-886868.3937230138,
1.374510850335198,
-886868.3937230138,
1.374510850335198,
-886868.3937230138,
1.374510850335198,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-32.755346551358684,
5.162917551035796e-05,
-32.7553713095164,
5.162919361109966e-05,
-32.755371520495,
5.1629194498694165e-05,
-32.7553740517406,
5.162919592464222e-05,
-32.75549815317228,
5.16292749500248e-05,
-32.7557671078149,
5.1629447239129814e-05,
-32.755537600705786,
5.162929920389579e-05,
-2395.136531414716,
0.0037120966016654976,
-32.7554490658762,
5.1629242163634275e-05,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-2395.136531414716,
0.0037120966016654976,
-65.50992369305769,
0.0001032573549102297,
-65.50924422087368,
0.00010325691856038897,
-65.50924420634506,
0.00010325691908177892,
-65.50924789343988,
0.00010325692011611614,
-65.50952699017678,
0.00010325709663260638,
-65.50953538930851,
0.00010325710204152489,
-65.50932045418728,
0.00010325696357174907,
-65.50995769649337,
0.00010325737519785258,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627,
-733.2050606371581,
-733.2050606371581,
0.0011363561025506627,
0.0011363561025506627
]
},
{
"requestedTime": 0.2,
"values": [
2.6370963314548135e-06,
1.1907436243229137e-06,
1.5914303294371521e-07,
0.0,
6.530827087152731e-22,
6.105198566652894e-27,
2.6364088989794256e-06,
1.1908086349821896e-06,
1.5912507766913015e-07,
2.636304998304695e-06,
1.1908227907789107e-06,
1.5912158717247314e-07,
2.6363827055320143e-06,
1.1908285194387939e-06,
1.5912015990557016e-07,
2.6363664073869586e-06,
1.1908301556228963e-06,
1.5911975015798518e-07,
2.6362371863797307e-06,
1.1908285541103296e-06,
1.5912015181363026e-07,
2.6363282813690604e-06,
1.190822777157153e-06,
1.5912159035091038e-07,
2.636463905218989e-06,
1.1908086605158301e-06,
1.5912507172531535e-07,
0.0,
1.409251022153514e-21,
2.583112155689631e-23,
-21824.884876930104,
0.06638877583716687,
-21824.8394513789,
0.06638874206868575,
-21824.88503639051,
0.06638877594459791,
-21825.04659229254,
0.06638889465525798,
-21825.449220760413,
0.06638918449710254,
-21860.3893940624,
0.06641207557540514,
-21825.44912779144,
0.06638918443617388,
-21825.046674584086,
0.06638889470996256,
-877968.2311707166,
1.2399623843859275,
-877968.2314488764,
1.239962386668905,
-877968.231641228,
1.2399623882475983,
-877968.2313630553,
1.23996238596452,
-2232.5529371119105,
0.0031580856642891347,
-2232.552938579765,
0.003158085669643602,
-2232.552934122746,
0.0031580856635922043,
-1392.944729430734,
0.002032422883230558,
-1391.7199124494437,
0.0020316357664157303,
-1391.7199702457754,
0.002031635646355635,
-1391.7199593465837,
0.00203163554240735,
-1391.7199471015297,
0.0020316355018333084,
-1391.719956464844,
0.0020316355405555213,
-1391.719971723481,
0.0020316356473075348,
-2232.5529379989352,
0.0031580856654872167,
-1391.7199093676563,
0.0020316357644361133,
-2232.552933678534,
0.003158085664480916,
-2232.552938069336,
0.0031580856704486304,
-2232.5529342591235,
0.003158085668637221,
-2232.5529350799816,
0.003158085669751649,
-145.14613577820415,
0.0023684760609916228,
-234.44464213523725,
0.002425822913152506,
-234.44208866732288,
0.0024258210179891326,
-234.43920235483773,
0.0024258190341378137,
-234.43948538748822,
0.0024258192159024635,
-234.4423567512829,
0.0024258211901560865,
-234.44466632040084,
0.002425822928687371,
-145.14616131190638,
0.0023684760773897006,
-665.721080447237,
-664.9052921895398,
0.0009423231603427254,
0.0009411988264117098,
-665.721080812701,
-664.9052927495628,
0.0009423231608531282,
0.0009411988270450505,
-665.7210807465625,
-664.9052926367955,
0.0009423231622138283,
0.0009411988283754829,
-665.7210793863333,
-664.9052912142575,
0.0009423231613560865,
0.0009411988274784932,
-665.7210790083244,
-664.9052908966471,
0.0009423231608560905,
0.0009411988270168083,
-665.721081019834,
-664.9052928469027,
0.0009423231621245239,
0.0009411988282463315,
-665.7210790496954,
-664.9052910102263,
0.0009423231594614548,
0.0009411988256686023,
-665.7210788464794,
-664.9052906188476,
0.0009423231596132578,
0.0009411988257017965
]
}
],
"outputContract": {
"sha256": "3a5cdcf25a6b841f7963044c5ecd1e8a3744fac98000eda53df2956ad0ac6506"
}
}
@@ -0,0 +1,198 @@
{
"schemaVersion": 1,
"id": "test_mql_8-progressive-v2",
"description": "Primary generic-solver regression target for the renamed 0.01 s-grid authoritative inputs, with a short smoke gate before staged extension.",
"source": {
"path": "tests/data/test-mql-8.xml",
"sha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
"bytes": 101013,
"xmlIsAuthoritative": true,
"companionProject": {
"path": "tests/data/test-mql-8.json",
"sha256": "258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa",
"bytes": 264675,
"executionInput": false
},
"simulation": {
"tStart": 0.0,
"tStop": 0.2,
"sampleStep": 0.01,
"maxStep": 0.01,
"method": "BDF"
}
},
"historicalReports": [
{
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-solver-only-v1.json",
"sha256": "ed0e8514c5fc083af6d403270eb0617fc20672ebb96460c355757d28d7059d0f",
"sourceXmlSha256": "42e2d6277d39bd5990cb322243bec8d71c40ff0696b440db3c8623f2ab15c2ee",
"status": "historicalOnly",
"compatibleWithCurrentSource": false,
"reason": "This report used the former spaced filename and 0.002 s sampleStep/maxStep. It is retained only as historical performance evidence and must never seed a v2 golden."
},
{
"path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-extension-decision.json",
"sha256": "d067bab69418ec21a11481f4a2c1fcb1ba1fc9528faeeb163ea5f466c2e2987f",
"sourceXmlSha256": "42e2d6277d39bd5990cb322243bec8d71c40ff0696b440db3c8623f2ab15c2ee",
"status": "historicalOnly",
"compatibleWithCurrentSource": false,
"reason": "This deferred-stage decision was calculated from the former 0.002 s source report and is not a runtime prediction for the renamed v2 source."
}
],
"structure": {
"componentCount": 152,
"connectionCount": 174,
"dynamicComponentCount": 54,
"stateCount": 124,
"resultVariableCount": 1716,
"pressureFlowUnknownCount": 760,
"pressureFlowEquationCount": 760,
"pressureFlowIsSquare": true,
"logicalEffortCoordinateCount": 112,
"eliminatedEffortAliasCount": 320,
"canonicalCoordinateCount": 440,
"compatibilityScatterCount": 760,
"jacobianNonzeroCount": 3296,
"jacobianColorGroupCount": 52,
"hasMechanicalStateEvents": true
},
"sequence": [
"0.01s-smoke",
"0.2s",
"1s",
"5s",
"10s"
],
"lanes": {
"solver-only": {
"description": "Algorithm iteration and staged-extension lane: same XML and method, with an in-memory 0.02 s output grid and 0.05 s maximum step; signal breakpoints remain explicit solver segments.",
"samplingMode": "fixed",
"sampleStep": 0.02,
"maxStepMode": "fixed",
"maxStep": 0.05,
"instrumentationMode": "standard",
"productionEquivalentOutput": false
},
"production": {
"description": "Acceptance lane: changes only tStop and preserves the source 0.01 s output and internal-step grid.",
"samplingMode": "source",
"maxStepMode": "source",
"instrumentationMode": "standard",
"productionEquivalentOutput": true
}
},
"variants": {
"0.01s-smoke": {
"stopTime": 0.01,
"softTimeoutSeconds": 60.0,
"hardTimeoutSeconds": 90.0,
"useForRuntimePrediction": false,
"checkpointTimes": [
0.0,
0.01
],
"expectedSignalEventTimes": []
},
"0.2s": {
"stopTime": 0.2,
"softTimeoutSeconds": 300.0,
"hardTimeoutSeconds": 360.0,
"checkpointTimes": [
0.0,
0.04,
0.2
],
"expectedSignalEventTimes": [
0.04
],
"expectedMechanicalTransitionTimes": [],
"goldens": {
"production": {
"path": "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json",
"sha256": "99fa7b3631a89f59f86551847175f3701a1567d8d0bd10d87545734f17515d72"
}
}
},
"1s": {
"stopTime": 1.0,
"softTimeoutSeconds": 1200.0,
"hardTimeoutSeconds": 1260.0,
"checkpointTimes": [
0.0,
0.04,
0.8,
1.0
],
"expectedSignalEventTimes": [
0.04,
0.8
]
},
"5s": {
"stopTime": 5.0,
"softTimeoutSeconds": 2700.0,
"hardTimeoutSeconds": 2760.0,
"checkpointTimes": [
0.0,
0.04,
0.8,
1.0,
2.0,
5.0
],
"expectedSignalEventTimes": [
0.04,
0.8
]
},
"10s": {
"stopTime": 10.0,
"softTimeoutSeconds": 5400.0,
"hardTimeoutSeconds": 5460.0,
"checkpointTimes": [
0.0,
0.04,
0.8,
1.0,
2.0,
5.0,
10.0
],
"expectedSignalEventTimes": [
0.04,
0.8
]
}
},
"correctness": {
"status": "partial",
"maximumScaledResidual": 1e-7,
"requireFiniteSeries": true,
"requireStrictlyIncreasingTimes": true,
"physicalProjectionCategories": [
"state"
],
"stateRelativeTolerance": 0.0002,
"stateAbsoluteTolerance": 1e-9,
"checkpointTimeAbsoluteToleranceSeconds": 1e-12,
"eventTimeAbsoluteToleranceSeconds": 0.00002,
"signalEventTimeAbsoluteToleranceSeconds": 1e-12,
"mechanicalTransitionTimesAvailable": true,
"note": "Physical state checkpoints/events and the value-free output-shape contract are separate. The production 0.2 s variant has an approved state golden; longer horizons and critical algebraic projections remain pending."
},
"execution": {
"causalExecutorV2Default": true,
"environment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
"predictionSafetyFactor": 1.5,
"terminationGraceSeconds": 10.0,
"deferAfterFailureOrTimeout": true,
"deferWhenPredictedWallExceedsSoftTimeout": true,
"longTestEnvironmentVariable": "RUN_TEST_MQL_8_LONG_REGRESSION"
}
}
@@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"kind": "progressive-extension-decision",
"sourceReport": "2026-08-17-solver-only-v1.json",
"sourceReportSha256": "ed0e8514c5fc083af6d403270eb0617fc20672ebb96460c355757d28d7059d0f",
"lane": "solver-only",
"observedCase": {
"caseId": "0.2s",
"outcome": "completed",
"wallSeconds": 132.30459557846189,
"acceptancePassed": true
},
"predictionSafetyFactor": 1.5,
"decisions": [
{
"caseId": "1s",
"outcome": "deferred",
"predictedWallSeconds": 992.2844668384642,
"softTimeoutSeconds": 900.0,
"reason": "predictedWallExceedsSoftBudget"
},
{
"caseId": "5s",
"outcome": "deferred",
"reason": "predecessorDeferred"
},
{
"caseId": "10s",
"outcome": "deferred",
"reason": "predecessorDeferred"
}
],
"simulationWasNotStartedForDeferredCases": true
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,37 @@
{
"schemaVersion": 1,
"kind": "progressive-extension-decision",
"manifestId": "test_mql_8-progressive-v2",
"sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
"sourceReport": "2026-08-17-production-v2-0.2.json",
"sourceReportSha256": "93baac975413703bebe940308ccd0de5dbd753f7c1c4fe015907315f112618a5",
"lane": "production",
"observedCase": {
"caseId": "0.2s",
"outcome": "completed",
"workerWallSeconds": 135.82402778230608,
"orchestrationWallSeconds": 136.63526012934744,
"acceptancePassed": true
},
"predictionSafetyFactor": 1.5,
"decisions": [
{
"caseId": "1s",
"outcome": "deferred",
"predictedWallSeconds": 1018.6802083672956,
"softTimeoutSeconds": 900.0,
"reason": "predictedWallExceedsSoftBudget"
},
{
"caseId": "5s",
"outcome": "deferred",
"reason": "predecessorDeferred"
},
{
"caseId": "10s",
"outcome": "deferred",
"reason": "predecessorDeferred"
}
],
"simulationWasNotStartedForDeferredCases": true
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,120 @@
{
"schemaVersion": 1,
"id": "test_mql_full_branches-historical-v1",
"description": "Historical slow-region and 2.05 s regression target for the generic solver.",
"source": {
"path": "tests/data/test_mql-full-branches-01-04.xml",
"sha256": "2fb95e65f5de0c85a6a17802aef74ea004087323fd00fd8d01acf0184ff71d48",
"bytes": 58860,
"xmlIsAuthoritative": true,
"simulation": {
"tStart": 0.0,
"tStop": 0.81,
"sampleStep": 0.01,
"maxStep": 0.81,
"method": "BDF"
}
},
"structure": {
"componentCount": 98,
"connectionCount": 106,
"dynamicComponentCount": 33,
"stateCount": 74,
"resultVariableCount": 1021,
"pressureFlowUnknownCount": 472,
"pressureFlowEquationCount": 472,
"pressureFlowIsSquare": true,
"logicalEffortCoordinateCount": 68,
"eliminatedEffortAliasCount": 204,
"canonicalCoordinateCount": 268,
"compatibilityScatterCount": 472,
"jacobianNonzeroCount": 1284,
"jacobianColorGroupCount": 31,
"hasMechanicalStateEvents": true
},
"sequence": [
"0.81s",
"2.10s"
],
"lanes": {
"solver-only": {
"description": "Historical solver regression using the authoritative 0.01 s output grid.",
"samplingMode": "fixed",
"sampleStep": 0.01,
"instrumentationMode": "standard",
"productionEquivalentOutput": true
},
"production": {
"description": "Acceptance lane preserving the authoritative XML output grid.",
"samplingMode": "source",
"instrumentationMode": "standard",
"productionEquivalentOutput": true
}
},
"variants": {
"0.81s": {
"stopTime": 0.81,
"softTimeoutSeconds": 180.0,
"hardTimeoutSeconds": 240.0,
"checkpointTimes": [
0.0,
0.04,
0.69,
0.70,
0.8,
0.81
],
"expectedSignalEventTimes": [
0.04,
0.8
],
"expectedMechanicalTransitionTimes": []
},
"2.10s": {
"stopTime": 2.1,
"softTimeoutSeconds": 300.0,
"hardTimeoutSeconds": 360.0,
"checkpointTimes": [
0.0,
0.04,
0.69,
0.70,
0.8,
1.0,
2.0,
2.05,
2.1
],
"expectedSignalEventTimes": [
0.04,
0.8
]
}
},
"correctness": {
"status": "partial",
"maximumScaledResidual": 1e-7,
"requireFiniteSeries": true,
"requireStrictlyIncreasingTimes": true,
"stateRelativeTolerance": 0.0002,
"eventTimeAbsoluteToleranceSeconds": 0.00002,
"signalEventTimeAbsoluteToleranceSeconds": 1e-12,
"mechanicalTransitionTimesAvailable": true,
"note": "Historical checkpoints are recorded; approved physical-state-v2 golden values remain pending."
},
"execution": {
"causalExecutorV2Default": true,
"environment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
"predictionSafetyFactor": 1.5,
"terminationGraceSeconds": 10.0,
"deferAfterFailureOrTimeout": true,
"deferWhenPredictedWallExceedsSoftTimeout": true,
"longTestEnvironmentVariable": "RUN_TEST_MQL_FULL_BRANCHES_LONG_REGRESSION"
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+258 -2
View File
@@ -3,8 +3,13 @@ from __future__ import annotations
import unittest
from app.simulation.components.amesim.flow.pipes import AmesimPnl00r
from app.simulation.components.amesim.junctions.nodes import AmesimPn3Node2
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
from app.simulation.solvers.stream import StreamResolver
from app.simulation.systems.network import SimulationNetwork
class AmesimPnl00rComponentTests(unittest.TestCase):
@@ -32,6 +37,23 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
{"gi": 1.5},
)
def test_initial_flow_temperature_reference_preserves_default_behavior(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
initial_h = self.medium.specific_enthalpy(self.medium.T_ref)
self.assertEqual(
pipe._connected_h,
{"port_1": initial_h, "port_2": initial_h},
)
self.assertAlmostEqual(pipe._port_temperature("port_1"), self.medium.T_ref)
self.assertAlmostEqual(pipe._port_temperature("port_2"), self.medium.T_ref)
forward = pipe.mass_flow(501000.0, 500000.0)
reverse = pipe.mass_flow(500000.0, 501000.0)
self.assertGreater(forward, 0.0)
self.assertLess(reverse, 0.0)
self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02)
def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
pipe.port_1.p = 100000.0
@@ -85,14 +107,24 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
le=1.0,
rr=0.045 / 14.0,
)
pipe.port_1.h_outflow = self.medium.specific_enthalpy(300.0)
pipe.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(300.0),
"port_2": self.medium.specific_enthalpy(300.0),
}
)
pipe._mass_flow_for_pressure_drop.cache_clear()
first = pipe.mass_flow(501000.0, 500000.0)
after_first = pipe._mass_flow_for_pressure_drop.cache_info()
second = pipe.mass_flow(501000.0, 500000.0)
after_second = pipe._mass_flow_for_pressure_drop.cache_info()
pipe.port_1.h_outflow = self.medium.specific_enthalpy(400.0)
pipe.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(400.0),
"port_2": self.medium.specific_enthalpy(300.0),
}
)
third = pipe.mass_flow(501000.0, 500000.0)
after_temperature_change = pipe._mass_flow_for_pressure_drop.cache_info()
@@ -105,6 +137,230 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
after_second.misses + 1,
)
def test_stream_outflows_are_crossed_but_flow_temperature_is_same_side(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium)
hot_h = self.medium.specific_enthalpy(420.0)
cold_h = self.medium.specific_enthalpy(240.0)
pipe.update_stream_outflows({"port_1": hot_h, "port_2": cold_h})
self.assertEqual(pipe.port_1.h_outflow, cold_h)
self.assertEqual(pipe.port_2.h_outflow, hot_h)
self.assertEqual(pipe._connected_h, {"port_1": hot_h, "port_2": cold_h})
self.assertAlmostEqual(pipe._port_temperature("port_1"), 420.0)
self.assertAlmostEqual(pipe._port_temperature("port_2"), 240.0)
warmer_h = self.medium.specific_enthalpy(460.0)
pipe.update_flow_temperature_references(
{"port_1": warmer_h, "port_2": cold_h}
)
self.assertEqual(
pipe._connected_h,
{"port_1": warmer_h, "port_2": cold_h},
)
self.assertAlmostEqual(pipe._port_temperature("port_1"), 460.0)
# Updating the pressure-flow-only reference must not change the
# zero-volume component's already propagated connector outflows.
self.assertEqual(pipe.port_1.h_outflow, cold_h)
self.assertEqual(pipe.port_2.h_outflow, hot_h)
def test_stream_closure_refreshes_same_side_cache_before_recomputed_flow(self) -> None:
hot_temperature = 420.0
cold_temperature = 240.0
source = Cylinder(
"source",
self.medium,
V=0.02,
p0=501000.0,
T0=hot_temperature,
)
pipe = AmesimPnl00r("pnl_1", self.medium)
sink = Tank(
"sink",
self.medium,
V=0.05,
p0=500000.0,
T0=cold_temperature,
)
network = SimulationNetwork("pnl00r-stream-refresh")
for component in (source, pipe, sink):
network.add_component(component)
network.connect("source", "port_b", "pnl_1", "port_1")
network.connect("pnl_1", "port_2", "sink", "port_a")
diagnostics, connected_h = StreamResolver(network).solve()
self.assertTrue(diagnostics.converged)
self.assertEqual(pipe._connected_h, connected_h["pnl_1"])
self.assertAlmostEqual(pipe._port_temperature("port_1"), hot_temperature)
self.assertAlmostEqual(pipe._port_temperature("port_2"), cold_temperature)
# The pressure-flow layer runs again after this stream closure pass.
# Its first recomputation therefore observes the freshly cached source
# enthalpy, independently of the crossed connector outflow values.
pipe.port_1.p = source.port_b.p
pipe.port_2.p = sink.port_a.p
flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p)
self.assertGreater(flow, 0.0)
def test_node_port_2_reference_refresh_does_not_replace_crossed_outflows(
self,
) -> None:
hot = Cylinder(
"hot",
self.medium,
V=0.1,
p0=100_000.0,
T0=400.0,
)
cold = Cylinder(
"cold",
self.medium,
V=0.1,
p0=100_000.0,
T0=200.0,
)
remote = Cylinder(
"remote",
self.medium,
V=0.1,
p0=100_000.0,
T0=300.0,
)
node = AmesimPn3Node2("node")
pipe = AmesimPnl00r("pnl_1", self.medium)
node.port_1.m_flow = 1.0
node.port_2.m_flow = -1.0
node.port_3.m_flow = 0.0
network = SimulationNetwork("pnl00r-node-reference")
for component in (hot, cold, remote, node, pipe):
network.add_component(component)
network.connect("hot", "port_b", "node", "port_1")
network.connect("cold", "port_b", "node", "port_3")
network.connect("node", "port_2", "pnl_1", "port_1")
network.connect("pnl_1", "port_2", "remote", "port_b")
resolver = StreamResolver(network)
diagnostics, connected_h = resolver.solve()
references = resolver.connected_temperature_reference_enthalpies()
outflows_before = (pipe.port_1.h_outflow, pipe.port_2.h_outflow)
self.assertTrue(diagnostics.converged)
self.assertNotEqual(node.port_2.h_outflow, node.temperature_reference_h)
self.assertEqual(pipe._connected_h, connected_h["pnl_1"])
self.assertNotEqual(
connected_h["pnl_1"]["port_1"],
references["pnl_1"]["port_1"],
)
resolver.refresh_flow_temperature_references()
self.assertEqual(pipe._connected_h, references["pnl_1"])
self.assertEqual(
pipe._connected_h["port_1"],
node.temperature_reference_h,
)
self.assertEqual(
(pipe.port_1.h_outflow, pipe.port_2.h_outflow),
outflows_before,
)
def test_hot_and_cold_upstream_flow_results_and_equations_are_consistent(self) -> None:
pipe = AmesimPnl00r(
"pnl_1",
self.medium,
diam=0.014,
le=1.0,
rr=0.045 / 14.0,
)
hot_temperature = 420.0
cold_temperature = 240.0
pipe.update_stream_outflows(
{
"port_1": self.medium.specific_enthalpy(hot_temperature),
"port_2": self.medium.specific_enthalpy(cold_temperature),
}
)
reference_hot = AmesimPnl00r(
"reference_hot",
self.medium,
diam=pipe.diam,
le=pipe.le,
rr=pipe.rr,
)
reference_hot.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(hot_temperature),
"port_2": self.medium.specific_enthalpy(hot_temperature),
}
)
reference_cold = AmesimPnl00r(
"reference_cold",
self.medium,
diam=pipe.diam,
le=pipe.le,
rr=pipe.rr,
)
reference_cold.update_flow_temperature_references(
{
"port_1": self.medium.specific_enthalpy(cold_temperature),
"port_2": self.medium.specific_enthalpy(cold_temperature),
}
)
cases = (
("forward_hot", 501000.0, 500000.0, hot_temperature, reference_hot),
("reverse_cold", 500000.0, 501000.0, cold_temperature, reference_cold),
)
for label, p_1, p_2, upstream_temperature, reference in cases:
with self.subTest(label=label):
pipe.port_1.p = p_1
pipe.port_2.p = p_2
reference.port_1.p = p_1
reference.port_2.p = p_2
expected_flow = reference.mass_flow(p_1, p_2)
actual_flow = pipe.mass_flow(p_1, p_2)
self.assertAlmostEqual(actual_flow, expected_flow)
pipe.port_1.m_flow = actual_flow
pipe.port_2.m_flow = -actual_flow
equation_values = pipe.pressure_flow_equation_values()
residual_values = tuple(
residual.value
for residual in pipe.pressure_flow_equation_residuals()
)
self.assertEqual(equation_values, (0.0, 0.0))
self.assertEqual(residual_values, equation_values)
results = pipe.component_result_values()
upstream_pressure = max(p_1, p_2)
density = self.medium.density(
upstream_pressure,
upstream_temperature,
)
expected_reynolds = pipe.reynolds_number(
actual_flow,
upstream_temperature,
)
self.assertAlmostEqual(results["re"], expected_reynolds)
self.assertAlmostEqual(
results["v"],
actual_flow / (density * pipe.area),
)
self.assertAlmostEqual(
results["cm"],
abs(actual_flow)
* upstream_temperature**0.5
/ (pipe.area * upstream_pressure),
)
self.assertAlmostEqual(
results["ff"],
min(pipe.friction_factor(expected_reynolds), 64_000_000.0),
)
def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None:
pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5)
+708
View File
@@ -0,0 +1,708 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import textwrap
import unittest
from unittest.mock import patch
from app.simulation.benchmark_regression import (
DEFAULT_MANIFEST_PATH,
RegressionCaseRequest,
RegressionManifestError,
derive_simulation_xml,
evaluate_regression_golden,
load_regression_manifest,
load_regression_golden,
main,
run_bounded_child_process,
run_regression_suite,
runtime_snapshot,
source_simulation_config,
summarize_simulation_result,
)
def _completed_result(request: RegressionCaseRequest, *, wall_seconds: float = 0.01):
signal_times = (
[0.04, 0.8]
if request.stop_time >= 1.0
else [0.04] if request.stop_time >= 0.04 else []
)
return {
"outcome": "completed",
"orchestrationWallSeconds": wall_seconds,
"worker": {
"outcome": "completed",
"wallSeconds": wall_seconds,
"lastSimulatedTime": request.stop_time,
"summary": {
"success": True,
"status": "completed",
"simulatedUntil": request.stop_time,
"seriesHealth": {
"seriesCount": 1,
"scalarCount": 2,
"nonfiniteCount": 0,
"timeStrictlyIncreasing": True,
"timeStart": 0.0,
"timeEnd": request.stop_time,
},
"checkpoints": [
{
"requestedTime": checkpoint,
"actualTime": checkpoint,
"available": True,
"stateValues": {"state.placeholder": 0.0},
}
for checkpoint in request.checkpoint_times
],
"diagnostics": {
"pressureFlow": {"maxScaledResidual": 1.0e-12},
},
"eventTrace": {
"signalEventTimes": signal_times,
"segments": [
{"startTime": start_time}
for start_time in (0.0, *signal_times)
],
"mechanicalTransitionTimes": [],
"mechanicalTransitionTimesAvailable": True,
},
"physicalContract": {
"schemaVersion": 1,
"projectionCategories": ["state"],
"checkpoints": [
{
"requestedTime": checkpoint,
"actualTime": checkpoint,
"available": True,
"stateValues": {"state.placeholder": 0.0},
}
for checkpoint in request.checkpoint_times
],
"eventTrace": {
"signalEventTimes": signal_times,
"segments": [
{"startTime": start_time}
for start_time in (0.0, *signal_times)
],
"mechanicalTransitionTimes": [],
"mechanicalTransitionTimesAvailable": True,
},
},
"outputContract": {
"schemaVersion": 1,
"sha256": "0" * 64,
"variableCount": 1,
"seriesKeyCount": 1,
"sampleCount": 2,
},
},
},
}
class RegressionManifestTests(unittest.TestCase):
def test_runtime_snapshot_records_repository_identity(self) -> None:
repository = runtime_snapshot()["repository"]
if repository["head"] is not None:
self.assertEqual(len(repository["head"]), 40)
self.assertIn(repository["dirty"], (True, False, None))
self.assertIsInstance(repository["status"], list)
def test_manifest_locks_authoritative_source_and_two_sampling_lanes(self) -> None:
manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
self.assertEqual(
manifest["sequence"],
["0.01s-smoke", "0.2s", "1s", "5s", "10s"],
)
self.assertTrue(manifest["source"]["xmlIsAuthoritative"])
self.assertEqual(
manifest["source"]["sha256"],
"170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3",
)
self.assertEqual(Path(manifest["_sourcePath"]).name, "test-mql-8.xml")
self.assertEqual(
manifest["source"]["companionProject"]["sha256"],
"258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa",
)
self.assertFalse(
manifest["source"]["companionProject"]["executionInput"]
)
self.assertEqual(manifest["historicalReports"][0]["status"], "historicalOnly")
self.assertFalse(
manifest["historicalReports"][0]["compatibleWithCurrentSource"]
)
self.assertEqual(manifest["lanes"]["production"]["samplingMode"], "source")
self.assertEqual(manifest["lanes"]["solver-only"]["sampleStep"], 0.02)
self.assertEqual(manifest["lanes"]["solver-only"]["maxStep"], 0.05)
def test_in_memory_derivative_does_not_change_authoritative_xml(self) -> None:
manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
source_path = Path(manifest["_sourcePath"])
before = source_path.read_bytes()
derived = derive_simulation_xml(
before,
stop_time=1.0,
sample_step=0.02,
max_step=0.005,
)
self.assertEqual(source_simulation_config(before)["tStop"], 0.2)
self.assertEqual(source_simulation_config(before)["sampleStep"], 0.01)
self.assertEqual(source_simulation_config(before)["maxStep"], 0.01)
self.assertEqual(source_simulation_config(derived)["tStop"], 1.0)
self.assertEqual(source_simulation_config(derived)["sampleStep"], 0.02)
self.assertEqual(source_simulation_config(derived)["maxStep"], 0.005)
self.assertEqual(source_path.read_bytes(), before)
def test_historical_pure_xml_manifest_does_not_require_companion(self) -> None:
historical_manifest_path = (
DEFAULT_MANIFEST_PATH.parent.parent
/ "test_mql_full_branches"
/ "manifest.json"
)
manifest = load_regression_manifest(historical_manifest_path)
self.assertNotIn("companionProject", manifest["source"])
self.assertIsNone(manifest["_companionPath"])
class RegressionGoldenProtocolTests(unittest.TestCase):
def test_reviewed_golden_is_provenanced_and_compared_numerically(self) -> None:
source_xml_sha256 = "1" * 64
output_contract_sha256 = "2" * 64
generated_at = "2026-08-17T00:00:00+00:00"
checkpoints = [{
"requestedTime": 0.0,
"actualTime": 0.0,
"available": True,
"stateValues": {"state.a": 1.0},
}]
summary = {
"physicalContract": {
"schemaVersion": 1,
"projectionCategories": ["state"],
"checkpoints": checkpoints,
"eventTrace": {},
},
"outputContract": {
"schemaVersion": 1,
"sha256": output_contract_sha256,
},
}
report = {
"generatedAt": generated_at,
"source": {"sha256": source_xml_sha256},
"cases": [{
"caseId": "smoke",
"lane": "solver-only",
"worker": {"summary": summary},
}],
}
with tempfile.TemporaryDirectory() as temporary_directory:
repository_root = Path(temporary_directory)
report_path = repository_root / "source-report.json"
report_path.write_text(
json.dumps(report, sort_keys=True), encoding="utf-8"
)
report_sha256 = hashlib.sha256(report_path.read_bytes()).hexdigest()
state_keys = ["state.a"]
state_layout_sha256 = hashlib.sha256(
json.dumps(
state_keys,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
golden = {
"schemaVersion": 1,
"id": "synthetic-reviewed-golden",
"caseId": "smoke",
"lane": "solver-only",
"sourceXmlSha256": source_xml_sha256,
"approval": {"status": "approved"},
"provenance": {
"sourceReport": {
"path": "source-report.json",
"sha256": report_sha256,
"generatedAt": generated_at,
"metadataCompatibility": {
"status": "current",
"differences": [],
},
}
},
"physicalLayout": {
"projectionCategories": ["state"],
"stateKeys": state_keys,
"stateKeyLayoutSha256": state_layout_sha256,
},
"tolerance": {
"relative": 0.001,
"absolute": 0.01,
"checkpointTimeAbsoluteSeconds": 1.0e-12,
},
"physicalCheckpoints": [{
"requestedTime": 0.0,
"values": [1.0],
}],
"outputContract": {"sha256": output_contract_sha256},
}
golden_path = repository_root / "golden.json"
golden_path.write_text(
json.dumps(golden, sort_keys=True), encoding="utf-8"
)
golden_sha256 = hashlib.sha256(golden_path.read_bytes()).hexdigest()
loaded = load_regression_golden(
golden_path,
expected_sha256=golden_sha256,
repository_root=repository_root,
)
passing = evaluate_regression_golden(summary, loaded)
self.assertTrue(passing["passed"])
self.assertEqual(passing["comparedValueCount"], 1)
self.assertEqual(passing["issues"], [])
changed_physical = json.loads(json.dumps(summary))
changed_physical["physicalContract"]["checkpoints"][0][
"stateValues"
]["state.a"] = 1.02
physical_failure = evaluate_regression_golden(changed_physical, loaded)
self.assertFalse(physical_failure["passed"])
self.assertIn(
"stateCheckpointGoldenValueMismatch", physical_failure["issues"]
)
changed_output = json.loads(json.dumps(summary))
changed_output["outputContract"]["sha256"] = "3" * 64
output_failure = evaluate_regression_golden(changed_output, loaded)
self.assertFalse(output_failure["passed"])
self.assertIn("outputContractMismatch", output_failure["issues"])
class BoundedChildProcessTests(unittest.TestCase):
def test_short_json_worker_completes_without_timeout(self) -> None:
script = textwrap.dedent(
"""
import json
print(json.dumps({"event": "progress", "simulatedTime": 0.1}), flush=True)
print(json.dumps({
"event": "result",
"outcome": "completed",
"lastSimulatedTime": 0.2,
"wallSeconds": 0.01,
}), flush=True)
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=1.0,
hard_timeout_seconds=2.0,
termination_grace_seconds=0.2,
)
self.assertEqual(result["outcome"], "completed")
self.assertFalse(result["softCancelSent"])
self.assertFalse(result["hardTimeoutReached"])
self.assertEqual(result["lastSimulatedTime"], 0.2)
def test_parent_requests_soft_cancel_before_hard_timeout(self) -> None:
script = textwrap.dedent(
"""
import json
import sys
print(json.dumps({"event": "progress", "simulatedTime": 0.125}), flush=True)
for line in sys.stdin:
if line.strip() == "cancel":
print(json.dumps({
"event": "result",
"outcome": "cancelled",
"lastSimulatedTime": 0.125,
"wallSeconds": 0.1,
}), flush=True)
break
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=0.08,
hard_timeout_seconds=1.0,
termination_grace_seconds=0.2,
)
self.assertEqual(result["outcome"], "soft_timeout")
self.assertTrue(result["softCancelSent"])
self.assertFalse(result["hardTimeoutReached"])
self.assertEqual(result["lastSimulatedTime"], 0.125)
def test_unresponsive_child_is_terminated_at_hard_timeout(self) -> None:
script = textwrap.dedent(
"""
import json
import time
print(json.dumps({"event": "progress", "simulatedTime": 0.05}), flush=True)
time.sleep(5)
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=0.05,
hard_timeout_seconds=0.15,
termination_grace_seconds=0.1,
)
self.assertEqual(result["outcome"], "hard_timeout")
self.assertTrue(result["softCancelSent"])
self.assertTrue(result["hardTimeoutReached"])
self.assertEqual(result["lastSimulatedTime"], 0.05)
def test_child_that_closes_stdin_does_not_break_pipe_cleanup(self) -> None:
script = textwrap.dedent(
"""
import os
import time
os.close(0)
time.sleep(5)
"""
)
result = run_bounded_child_process(
[sys.executable, "-u", "-c", script],
soft_timeout_seconds=0.05,
hard_timeout_seconds=0.15,
termination_grace_seconds=0.1,
)
self.assertEqual(result["outcome"], "hard_timeout")
self.assertTrue(result["softCancelSent"])
self.assertTrue(result["hardTimeoutReached"])
class ProgressiveSuiteTests(unittest.TestCase):
def test_requesting_late_case_also_runs_every_predecessor(self) -> None:
calls: list[str] = []
def complete(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
return _completed_result(request)
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["5s"],
case_executor=complete,
)
self.assertEqual(calls, ["0.01s-smoke", "0.2s", "1s", "5s"])
self.assertEqual(
[case["outcome"] for case in report["cases"]],
["completed", "completed", "completed", "completed"],
)
def test_manifest_pins_solver_modes_in_every_request(self) -> None:
seen: list[dict[str, str]] = []
def complete(request: RegressionCaseRequest) -> dict[str, object]:
seen.append(dict(request.environment_overrides))
return _completed_result(request)
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["0.01s-smoke"],
case_executor=complete,
)
self.assertEqual(
seen,
[{
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on",
}],
)
def test_empty_explicit_case_list_is_rejected(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError,
"No regression variants were selected",
):
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=[],
case_executor=_completed_result,
)
def test_missing_signal_segment_fails_acceptance(self) -> None:
def missing_segment(request: RegressionCaseRequest) -> dict[str, object]:
result = _completed_result(request)
result["worker"]["summary"]["eventTrace"]["segments"] = [
{"startTime": 0.0}
]
return result
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["0.2s"],
case_executor=missing_segment,
)
self.assertEqual(report["cases"][1]["outcome"], "correctness_failed")
self.assertIn(
"signalEventSegmentMissing",
report["cases"][1]["acceptance"]["issues"],
)
def test_deferred_suite_returns_nonzero_incomplete_exit_status(self) -> None:
report = {"cases": [
{"outcome": "completed"},
{"outcome": "deferred"},
]}
with patch(
"app.simulation.benchmark_regression.run_regression_suite",
return_value=report,
), patch("builtins.print"):
exit_code = main([])
self.assertEqual(exit_code, 2)
def test_nonfinite_completed_case_fails_acceptance_and_defers_longer_runs(
self,
) -> None:
calls: list[str] = []
def nonfinite_first(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
result = _completed_result(request)
result["worker"]["summary"]["seriesHealth"]["nonfiniteCount"] = 1
return result
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_executor=nonfinite_first,
)
self.assertEqual(calls, ["0.01s-smoke"])
self.assertEqual(report["cases"][0]["outcome"], "correctness_failed")
self.assertEqual(
report["cases"][0]["acceptance"]["issues"],
["nonfiniteSeries"],
)
self.assertTrue(
all(case["outcome"] == "deferred" for case in report["cases"][1:])
)
def test_failed_predecessor_defers_every_longer_horizon(self) -> None:
calls: list[str] = []
def fail_first(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
return {"outcome": "error", "worker": None}
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_executor=fail_first,
)
self.assertEqual(calls, ["0.01s-smoke"])
self.assertEqual(report["cases"][0]["outcome"], "error")
self.assertEqual(
[case["outcome"] for case in report["cases"][1:]],
["deferred", "deferred", "deferred", "deferred"],
)
self.assertTrue(
all(
case["reason"] == "predecessorDidNotComplete"
for case in report["cases"][1:]
)
)
def test_prediction_over_budget_defers_before_launching_next_case(self) -> None:
calls: list[str] = []
def expensive_short_case(request: RegressionCaseRequest) -> dict[str, object]:
calls.append(request.case_id)
return _completed_result(request, wall_seconds=200.0)
report = run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_executor=expensive_short_case,
)
self.assertEqual(calls, ["0.01s-smoke", "0.2s"])
self.assertIsNone(report["cases"][1]["predictedWallSeconds"])
third = report["cases"][2]
self.assertEqual(third["outcome"], "deferred")
self.assertEqual(third["reason"], "predictedWallExceedsSoftBudget")
self.assertEqual(third["predictedWallSeconds"], 1500.0)
def test_lanes_pass_distinct_sampling_to_case_executor(self) -> None:
seen: list[tuple[str, float, float]] = []
def complete(request: RegressionCaseRequest) -> dict[str, object]:
seen.append((request.lane, request.sample_step, request.max_step))
return _completed_result(request)
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="solver-only",
case_ids=["0.01s-smoke"],
case_executor=complete,
)
run_regression_suite(
DEFAULT_MANIFEST_PATH,
lane="production",
case_ids=["0.01s-smoke"],
case_executor=complete,
)
self.assertEqual(
seen,
[("solver-only", 0.02, 0.05), ("production", 0.01, 0.01)],
)
class ResultSummaryTests(unittest.TestCase):
def test_summary_preserves_diagnostics_and_available_event_trace(self) -> None:
result = {
"success": True,
"status": "completed",
"partial": False,
"message": "done",
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
"variables": [
{"key": "tank.m", "category": "state"},
{"key": "tank.p", "category": "thermodynamic"},
],
"series": {
"time": [0.0, 0.2],
"tank.m": [1.0, 0.9],
"tank.p": [2.0, 1.5],
},
"final": {"tank.m": 0.9, "tank.p": 1.5},
"diagnostics": {
"stateCount": 1,
"signal": {"eventTimes": [0.04]},
"integration": {
"totals": {
"stateTransitionCount": 2,
"solverStartCount": 3,
},
"segments": [
{
"startTime": 0.0,
"requestedStopTime": 0.2,
"simulatedUntil": 0.2,
"stateTransitionCount": 2,
"stateTransitionTimes": [0.11, 0.17],
"solverStartCount": 3,
"recoverableRetryCount": 0,
}
],
},
},
}
summary = summarize_simulation_result(
result,
checkpoint_times=(0.0, 0.2),
sample_step=0.2,
)
self.assertEqual(summary["diagnostics"], result["diagnostics"])
self.assertEqual(summary["eventTrace"]["signalEventTimes"], [0.04])
self.assertEqual(summary["eventTrace"]["stateTransitionCount"], 2)
self.assertTrue(
summary["eventTrace"]["mechanicalTransitionTimesAvailable"]
)
self.assertEqual(
summary["eventTrace"]["mechanicalTransitionTimes"],
[0.11, 0.17],
)
self.assertEqual(
summary["checkpoints"][1]["stateValues"], {"tank.m": 0.9}
)
self.assertEqual(
summary["physicalContract"]["checkpoints"], summary["checkpoints"]
)
self.assertEqual(
summary["physicalContract"]["eventTrace"], summary["eventTrace"]
)
self.assertFalse(summary["outputContract"]["containsPhysicalValues"])
self.assertEqual(summary["seriesHealth"]["nonfiniteCount"], 0)
changed_values = json.loads(json.dumps(result))
changed_values["series"]["tank.m"] = [99.0, -99.0]
changed_values["series"]["tank.p"] = [-2.0, 1000.0]
value_changed_summary = summarize_simulation_result(
changed_values,
checkpoint_times=(0.0, 0.2),
sample_step=0.2,
)
self.assertEqual(
value_changed_summary["outputContract"]["sha256"],
summary["outputContract"]["sha256"],
)
changed_metadata = json.loads(json.dumps(result))
changed_metadata["variables"][0]["unit"] = "kg"
metadata_changed_summary = summarize_simulation_result(
changed_metadata,
checkpoint_times=(0.0, 0.2),
sample_step=0.2,
)
self.assertNotEqual(
metadata_changed_summary["outputContract"]["sha256"],
summary["outputContract"]["sha256"],
)
def test_missing_mechanical_transition_times_are_reported_unavailable(
self,
) -> None:
summary = summarize_simulation_result(
{
"diagnostics": {
"integration": {
"totals": {"stateTransitionCount": 1},
"segments": [{
"startTime": 0.0,
"stateTransitionCount": 1,
}],
},
},
},
checkpoint_times=(),
sample_step=0.1,
)
self.assertFalse(
summary["eventTrace"]["mechanicalTransitionTimesAvailable"]
)
if __name__ == "__main__":
unittest.main()
+281
View File
@@ -0,0 +1,281 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
import unittest
from app.main import compile_reactflow_network, compile_system_xml_network
from app.simulation.solvers.causal_ir import (
CausalIROpcode,
compile_causal_numeric_ir,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
from tests.test_generic_system_xml_simulation import chain_project
TARGET_XML = Path("tests/data/test-mql-8.xml")
def _system(project) -> GenericFluidSystem:
return GenericFluidSystem(compile_reactflow_network(project))
class CausalNumericIRTests(unittest.TestCase):
def test_structure_signature_is_stable_and_excludes_bindings(self) -> None:
first = compile_causal_numeric_ir(
_system(zero_force_mass_project()).pressure_flow_solver
)
second = compile_causal_numeric_ir(
_system(zero_force_mass_project()).pressure_flow_solver
)
self.assertTrue(first.supported)
self.assertTrue(second.supported)
assert first.ir is not None and second.ir is not None
self.assertEqual(
first.ir.program.structural_signature,
second.ir.program.structural_signature,
)
self.assertEqual(
first.ir.program.structural_signature,
first.ir.program.calculate_structural_signature(),
)
self.assertEqual(len(first.ir.program.structural_signature), 64)
changed_binding = replace(
first.ir,
bindings=replace(
first.ir.bindings,
evaluators=tuple(
(lambda evaluate=evaluate: evaluate())
for evaluate in first.ir.bindings.evaluators
),
),
)
self.assertEqual(
changed_binding.program.structural_signature,
first.ir.program.structural_signature,
)
def test_reference_interpreter_matches_existing_object_plan_bitwise(self) -> None:
system = _system(high_pressure_helium_step_project())
state = system.initial_state_vector()
system.rhs(0.041, state)
solver = system.pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
before = tuple(unknown.read() for unknown in solver.unknowns)
self.assertTrue(solver._execute_causal_effort_plan(("p",)))
for unknown in solver._explicit_flow_unknowns_by_variables[
frozenset(("f", "m_flow"))
]:
unknown.write(0.0)
expected_flow_stages: list[tuple[float, ...]] = []
for stage in solver._explicit_flow_plan:
values = solver._evaluate_explicit_flow_stage(stage)
expected_flow_stages.append(values)
for assignment, value in zip(stage.assignments, values):
assignment.unknown.write(value)
expected = tuple(unknown.read() for unknown in solver.unknowns)
for unknown, value in zip(solver.unknowns, before):
unknown.write(value)
workspace = compilation.ir.create_workspace()
observed: list[tuple[str, int, tuple[int, ...], tuple[float, ...]]] = []
result = compilation.ir.execute(
workspace,
stage_observer=lambda phase, index, slots, values: observed.append(
(phase, index, slots, values)
),
)
actual = tuple(unknown.read() for unknown in solver.unknowns)
self.assertTrue(result.success, result.fallback_reason)
self.assertEqual(actual, expected)
self.assertEqual(
result.effort_assignment_count,
len(compilation.ir.program.effort_stages[0].operations),
)
self.assertEqual(
result.flow_assignment_count,
compilation.ir.program.flow_assignment_count,
)
self.assertEqual(observed[0][0], "effort:p")
self.assertEqual(
sum(item[0] == "flow" for item in observed),
len(compilation.ir.program.flow_stages),
)
self.assertEqual(
tuple(item[3] for item in observed if item[0] == "flow"),
tuple(expected_flow_stages),
)
def test_workspace_is_reused_and_normal_execution_does_not_snapshot(self) -> None:
system = _system(zero_force_mass_project())
system.rhs(0.0, system.initial_state_vector())
compilation = compile_causal_numeric_ir(system.pressure_flow_solver)
assert compilation.ir is not None
workspace = compilation.ir.create_workspace()
array_ids = (
id(workspace.canonical_values),
id(workspace.effort_residuals),
id(workspace.flow_values),
id(workspace.transaction_values),
)
workspace.transaction_values.fill(float("nan"))
first = compilation.ir.execute(workspace)
second = compilation.ir.execute(workspace)
self.assertTrue(first.success)
self.assertTrue(second.success)
self.assertEqual(
array_ids,
(
id(workspace.canonical_values),
id(workspace.effort_residuals),
id(workspace.flow_values),
id(workspace.transaction_values),
),
)
self.assertTrue(
all(value != value for value in workspace.transaction_values)
)
def test_transactional_audit_rolls_back_partial_flow_failure(self) -> None:
system = _system(zero_force_mass_project())
system.rhs(0.0, system.initial_state_vector())
solver = system.pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
assert compilation.ir is not None
ir = compilation.ir
before = tuple(unknown.read() for unknown in solver.unknowns)
operation = ir.program.flow_stages[0].operations[0]
evaluators = list(ir.bindings.evaluators)
original = evaluators[operation.evaluator_slot]
if operation.opcode is CausalIROpcode.FLOW_DIRECT:
evaluators[operation.evaluator_slot] = lambda: float("nan")
else:
equation_index = operation.equation_indices[0]
def one_nonfinite_component():
values = list(original())
values[equation_index] = float("nan")
return tuple(values)
evaluators[operation.evaluator_slot] = one_nonfinite_component
failing_ir = replace(
ir,
bindings=replace(ir.bindings, evaluators=tuple(evaluators)),
)
result = failing_ir.execute(
failing_ir.create_workspace(),
transactional=True,
)
self.assertFalse(result.success)
self.assertEqual(result.fallback_reason, "nonFiniteFlowAssignment")
self.assertTrue(result.rolled_back)
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
before,
)
def test_unsupported_plan_returns_existing_proof_reason(self) -> None:
solver = _system(chain_project()).pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
self.assertFalse(compilation.supported)
self.assertIsNone(compilation.ir)
self.assertEqual(
compilation.fallback_reason,
solver._causal_fast_path_fallback_reason,
)
class TargetCausalNumericIRStructureTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())
assert report.valid and report.document is not None
cls.system = GenericFluidSystem(
compile_system_xml_network(report.document)
)
cls.compilation = compile_causal_numeric_ir(
cls.system.pressure_flow_solver
)
def test_target_has_expected_canonical_and_compatibility_coordinates(
self,
) -> None:
self.assertTrue(
self.compilation.supported,
self.compilation.fallback_reason,
)
assert self.compilation.ir is not None
program = self.compilation.ir.program
self.assertEqual(len(program.compatibility_slots), 760)
self.assertEqual(len(program.canonical_slots), 440)
self.assertEqual(program.effort_group_count, 112)
self.assertEqual(program.flow_assignment_count, 328)
self.assertEqual(program.effort_scatter_count, 432)
self.assertEqual(program.eliminated_effort_replica_count, 320)
self.assertEqual(
tuple(slot.slot for slot in program.canonical_slots),
tuple(range(440)),
)
def test_target_batches_efforts_and_preserves_flow_stage_layout(self) -> None:
assert self.compilation.ir is not None
program = self.compilation.ir.program
pressure_stage = next(
stage for stage in program.effort_stages if stage.variable == "p"
)
self.assertEqual(len(pressure_stage.operations), 72)
self.assertEqual(len(pressure_stage.evaluations), 36)
self.assertTrue(
all(
evaluation.opcode
is CausalIROpcode.EFFORT_COMPONENT_RESIDUAL
for evaluation in pressure_stage.evaluations
)
)
self.assertEqual(
[len(stage.target_slots) for stage in program.flow_stages],
[110, 130, 49, 33, 5, 1],
)
def test_target_reference_execution_matches_all_compatibility_slots(self) -> None:
assert self.compilation.ir is not None
solver = self.system.pressure_flow_solver
self.system.rhs(0.0, self.system.initial_state_vector())
before = tuple(unknown.read() for unknown in solver.unknowns)
self.assertTrue(solver._execute_causal_effort_plan(("p",)))
self.assertIsNone(solver._execute_compiled_causal_flow_plan())
expected = tuple(unknown.read() for unknown in solver.unknowns)
for unknown, value in zip(solver.unknowns, before):
unknown.write(value)
result = self.compilation.ir.execute(
self.compilation.ir.create_workspace()
)
self.assertTrue(result.success, result.fallback_reason)
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
expected,
)
if __name__ == "__main__":
unittest.main()
+477 -1
View File
@@ -203,6 +203,82 @@ class IntegrateOdeTests(unittest.TestCase):
self.assertEqual(calls[0]["max_step"], 1.0e-3)
self.assertNotIn("first_step", calls[0])
def test_recoverable_trial_retries_default_keeps_direct_scipy_route(
self,
) -> None:
import scipy.integrate
expected = object()
with patch.object(
scipy.integrate,
"solve_ivp",
return_value=expected,
) as direct_solve:
result = integrate_ode(
rhs=lambda _time, state: state,
initial_state=[1.0],
config=SolveIVPConfig(t_start=0.0, t_stop=1.0),
)
self.assertIs(result, expected)
direct_solve.assert_called_once()
def test_recoverable_stepwise_route_matches_direct_scipy_without_failures(
self,
) -> None:
import numpy as np
sample_times = [0.0, 0.025, 0.05, 0.075, 0.1]
def rhs(time, state):
return [-2.0 * float(state[0]) + math.sin(float(time))]
for method in ("RK45", "BDF"):
with self.subTest(method=method):
config = SolveIVPConfig(
t_start=0.0,
t_stop=0.1,
method=method,
rtol=1.0e-9,
atol=1.0e-12,
max_step=0.01,
)
direct = integrate_ode(
rhs=rhs,
initial_state=[1.0],
config=config,
t_eval=sample_times,
)
stepwise = integrate_ode(
rhs=rhs,
initial_state=[1.0],
config=config,
t_eval=sample_times,
recoverable_trial_retries=True,
)
self.assertTrue(direct.success, direct.message)
self.assertTrue(stepwise.success, stepwise.message)
np.testing.assert_array_equal(direct.t, stepwise.t)
np.testing.assert_allclose(
direct.y,
stepwise.y,
rtol=1.0e-12,
atol=1.0e-14,
)
self.assertEqual(
int(direct.nfev),
sum(segment.nfev for segment in stepwise.solver_segments),
)
self.assertEqual(
int(getattr(direct, "njev", 0)),
sum(segment.njev for segment in stepwise.solver_segments),
)
self.assertEqual(
int(getattr(direct, "nlu", 0)),
sum(segment.nlu for segment in stepwise.solver_segments),
)
def test_stepwise_solver_rebuilds_after_recoverable_trial_failure(self) -> None:
import numpy as np
import scipy.integrate
@@ -241,7 +317,7 @@ class IntegrateOdeTests(unittest.TestCase):
max_step=1.0,
),
t_eval=[0.0, 1.0],
cancel_check=lambda: False,
recoverable_trial_retries=True,
)
self.assertTrue(result.success, result.message)
@@ -249,6 +325,392 @@ class IntegrateOdeTests(unittest.TestCase):
self.assertEqual(result.t, [0.0, 1.0])
self.assertEqual(result.y, [[1.0, 1.0]])
def test_stepwise_solver_retries_recoverable_constructor_failure(self) -> None:
import numpy as np
import scipy.integrate
attempts: list[dict[str, object]] = []
class ConstructorRetryBdf:
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
attempts.append(dict(kwargs))
if float(kwargs["max_step"]) > 0.25:
raise RecoverableTrialStateError("constructor trial failed")
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.status = "running"
def step(self):
self.t = self.t_bound
self.status = "finished"
return None
def dense_output(self):
state = self.y.copy()
return lambda _time: state.copy()
with patch.object(scipy.integrate, "BDF", ConstructorRetryBdf):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertNotIn("first_step", attempts[0])
self.assertEqual(
[attempt["max_step"] for attempt in attempts],
[1.0, 0.5, 0.25],
)
self.assertEqual(
[attempt.get("first_step") for attempt in attempts],
[None, 0.5, 0.25],
)
segment = result.solver_segments[0]
self.assertEqual(segment.recoverable_retry_count, 2)
self.assertEqual(
[retry.phase for retry in segment.recoverable_retries],
["constructor", "constructor"],
)
self.assertEqual(
segment.as_dict()["recoverableRetries"][0],
{
"phase": "constructor",
"attemptedStep": 1.0,
"reason": "constructor trial failed",
"nextMaxStep": 0.5,
"nextFirstStep": 0.5,
},
)
def test_stepwise_retry_uses_solver_actual_step_not_segment_maximum(
self,
) -> None:
import numpy as np
import scipy.integrate
attempts: list[dict[str, object]] = []
class ActualStepRetryBdf:
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
self.attempt_index = len(attempts)
attempts.append(dict(kwargs))
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.h_abs = min(0.04, float(kwargs["max_step"]))
self.step_size = min(0.03, float(kwargs["max_step"]))
self.status = "running"
def step(self):
if self.attempt_index == 0:
raise RecoverableTrialStateError("small actual trial failed")
self.t = self.t_bound
self.status = "finished"
return None
def dense_output(self):
state = self.y.copy()
return lambda _time: state.copy()
with patch.object(scipy.integrate, "BDF", ActualStepRetryBdf):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertNotIn("first_step", attempts[0])
self.assertEqual(attempts[1]["max_step"], 0.02)
self.assertEqual(attempts[1]["first_step"], 0.02)
retry = result.solver_segments[0].recoverable_retries[0]
self.assertEqual(retry.phase, "step")
self.assertEqual(retry.attempted_step, 0.04)
self.assertEqual(retry.next_max_step, 0.02)
def test_stepwise_retry_falls_back_to_previous_step_size_without_h_abs(
self,
) -> None:
import numpy as np
import scipy.integrate
for invalid_h_abs in (None, math.nan):
with self.subTest(h_abs=invalid_h_abs):
attempts: list[dict[str, object]] = []
class StepSizeFallbackBdf:
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
self.attempt_index = len(attempts)
attempts.append(dict(kwargs))
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.h_abs = invalid_h_abs
self.step_size = 0.06
self.status = "running"
def step(self):
if self.attempt_index == 0:
raise RecoverableTrialStateError(
"trial without valid h_abs"
)
self.t = self.t_bound
self.status = "finished"
return None
def dense_output(self):
state = self.y.copy()
return lambda _time: state.copy()
with patch.object(
scipy.integrate,
"BDF",
StepSizeFallbackBdf,
):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
cancel_check=lambda: False,
)
self.assertTrue(result.success, result.message)
self.assertEqual(attempts[1]["max_step"], 0.03)
self.assertEqual(attempts[1]["first_step"], 0.03)
retry = result.solver_segments[0].recoverable_retries[0]
self.assertEqual(retry.attempted_step, 0.06)
self.assertEqual(retry.next_max_step, 0.03)
def test_accepted_step_clears_recoverable_retry_state(self) -> None:
import numpy as np
import scipy.integrate
attempts: list[dict[str, object]] = []
caps_after_accepted_retry: list[float] = []
class FailureAfterAcceptedRetryBdf:
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
self.attempt_index = len(attempts)
attempts.append(dict(kwargs))
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.h_abs = min(0.2, float(kwargs["max_step"]))
self.status = "running"
self.step_count = 0
def step(self):
self.step_count += 1
if self.attempt_index == 0:
raise RecoverableTrialStateError("retry once")
if self.step_count == 1:
self.t = 0.25
return None
caps_after_accepted_retry.append(float(self.max_step))
self.status = "failed"
return "ordinary failure after accepted step"
with patch.object(
scipy.integrate,
"BDF",
FailureAfterAcceptedRetryBdf,
):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
cancel_check=lambda: False,
)
self.assertFalse(result.success)
self.assertEqual(result.message, "ordinary failure after accepted step")
self.assertEqual(len(attempts), 2)
self.assertEqual(caps_after_accepted_retry, [1.0])
self.assertEqual(result.solver_segments[0].accepted_step_count, 1)
self.assertEqual(result.solver_segments[0].recoverable_retry_count, 1)
def test_transition_restart_does_not_inherit_retry_first_step(self) -> None:
import numpy as np
import scipy.integrate
attempts: list[dict[str, object]] = []
transition_pending = True
class TransitionAfterRetryBdf:
def __init__(self, _fun, t0, y0, t_bound, **kwargs):
self.attempt_index = len(attempts)
attempts.append(dict(kwargs))
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.h_abs = min(0.2, float(kwargs["max_step"]))
self.status = "running"
def step(self):
if self.attempt_index == 0:
raise RecoverableTrialStateError("retry before transition")
self.t = self.t_bound
self.y = np.asarray([self.t], dtype=float)
self.status = "finished"
return None
def dense_output(self):
return lambda time: np.asarray([float(time)], dtype=float)
def transition_handler(
previous_time,
_previous_state,
current_time,
_current_state,
_dense_state,
):
nonlocal transition_pending
if transition_pending and previous_time <= 0.25 <= current_time:
transition_pending = False
return StateTransition(time=0.25, state=[0.25])
return None
with patch.object(scipy.integrate, "BDF", TransitionAfterRetryBdf):
result = integrate_ode(
rhs=lambda _time, _state: [1.0],
initial_state=[0.0],
config=SolveIVPConfig(
t_start=0.0,
t_stop=1.0,
method="BDF",
max_step=1.0,
),
state_transition_handler=transition_handler,
)
self.assertTrue(result.success, result.message)
self.assertEqual(len(attempts), 3)
self.assertNotIn("first_step", attempts[0])
self.assertEqual(attempts[1]["first_step"], 0.1)
self.assertEqual(attempts[1]["max_step"], 0.1)
self.assertNotIn("first_step", attempts[2])
self.assertEqual(attempts[2]["max_step"], 1.0)
def test_recoverable_retry_stops_at_minimum_step(self) -> None:
import numpy as np
import scipy.integrate
minimum_step = 64.0 * math.ulp(1.0)
attempts = 0
class MinimumStepBdf:
def __init__(self, _fun, t0, y0, _t_bound, **_kwargs):
nonlocal attempts
attempts += 1
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.h_abs = 2.0 * minimum_step
self.status = "running"
def step(self):
raise RecoverableTrialStateError("minimum step reached")
with patch.object(scipy.integrate, "BDF", MinimumStepBdf):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
cancel_check=lambda: False,
)
self.assertFalse(result.success)
self.assertEqual(result.message, "minimum step reached")
self.assertEqual(attempts, 1)
retry = result.solver_segments[0].recoverable_retries[0]
self.assertEqual(retry.attempted_step, 2.0 * minimum_step)
self.assertIsNone(retry.next_max_step)
def test_recoverable_retry_has_sixteen_retry_limit(self) -> None:
import scipy.integrate
attempts: list[dict[str, object]] = []
class AlwaysFailingConstructorBdf:
def __init__(self, _fun, _t0, _y0, _t_bound, **kwargs):
attempts.append(dict(kwargs))
raise RecoverableTrialStateError("persistent trial failure")
with patch.object(
scipy.integrate,
"BDF",
AlwaysFailingConstructorBdf,
):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
cancel_check=lambda: False,
)
self.assertFalse(result.success)
self.assertEqual(len(attempts), 17)
diagnostics = result.solver_segments[0].recoverable_retries
self.assertEqual(len(diagnostics), 17)
self.assertEqual(
sum(retry.next_max_step is not None for retry in diagnostics),
16,
)
self.assertIsNone(diagnostics[-1].next_max_step)
def test_recoverable_retry_does_not_swallow_cancellation(self) -> None:
import numpy as np
import scipy.integrate
attempts = 0
class CancellingBdf:
def __init__(self, _fun, t0, y0, _t_bound, **_kwargs):
nonlocal attempts
attempts += 1
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.status = "running"
def step(self):
raise IntegrationCancelled
with patch.object(scipy.integrate, "BDF", CancellingBdf):
result = integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[1.0],
config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0),
cancel_check=lambda: False,
)
self.assertFalse(result.success)
self.assertEqual(result.status, "cancelled")
self.assertEqual(attempts, 1)
self.assertEqual(result.solver_segments[0].recoverable_retry_count, 0)
def test_stepwise_solver_does_not_retry_ordinary_model_errors(self) -> None:
import numpy as np
import scipy.integrate
@@ -743,6 +1205,20 @@ class IntegrateOdeTests(unittest.TestCase):
self.assertAlmostEqual(result.y[0][1], 0.0, places=12)
self.assertAlmostEqual(result.y[0][2], 0.05, places=8)
self.assertAlmostEqual(result.y[0][-1], 0.65, places=8)
transition_segments = [
segment
for segment in result.solver_segments
if segment.state_transition_count
]
self.assertEqual(len(transition_segments), 1)
self.assertEqual(
transition_segments[0].state_transition_times,
(event_time,),
)
self.assertEqual(
transition_segments[0].as_dict()["stateTransitionTimes"],
[event_time],
)
def test_state_transitions_chain_at_same_time_until_state_repeats(self) -> None:
event_time = 0.25
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from importlib import metadata
import os
from pathlib import Path
import re
import sys
import unittest
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
REQUIREMENTS_PATH = REPOSITORY_ROOT / "requirements.txt"
CONSTRAINTS_PATH = (
REPOSITORY_ROOT / "constraints" / "python312-direct.txt"
)
PYTHON_VERSION_PATH = REPOSITORY_ROOT / ".python-version"
VERIFY_ENVIRONMENT_VARIABLE = "SYSTEM_SIMULATION_VERIFY_LOCKED_ENV"
REFERENCE_DIRECT_VERSIONS = {
"fastapi": "0.141.1",
"lxml": "6.1.1",
"numpy": "2.5.2",
"pydantic": "2.13.4",
"scipy": "1.18.0",
"uvicorn": "0.52.3",
}
def _active_lines(path: Path) -> tuple[str, ...]:
return tuple(
line
for raw_line in path.read_text(encoding="utf-8").splitlines()
if (line := raw_line.strip()) and not line.startswith("#")
)
def _normalized_name(requirement: str) -> str:
match = re.match(r"[A-Za-z0-9_.-]+", requirement)
if match is None:
raise AssertionError(f"Invalid requirement line: {requirement!r}")
return match.group(0).lower().replace("_", "-")
def _constraint_versions() -> dict[str, str]:
result: dict[str, str] = {}
for line in _active_lines(CONSTRAINTS_PATH):
parts = line.split("==")
if len(parts) != 2 or not all(parts):
raise AssertionError(
f"Direct constraint must be an exact package pin: {line!r}"
)
name, version = parts
normalized = _normalized_name(name)
if normalized in result:
raise AssertionError(f"Duplicate direct constraint: {normalized}")
result[normalized] = version
return result
class DependencyConstraintContractTests(unittest.TestCase):
def test_python_reference_version_is_explicit(self) -> None:
self.assertEqual(
PYTHON_VERSION_PATH.read_text(encoding="utf-8").strip(),
"3.12.3",
)
def test_requirements_remain_ranges_and_declare_every_direct_package(
self,
) -> None:
requirements = _active_lines(REQUIREMENTS_PATH)
names = {_normalized_name(line) for line in requirements}
self.assertEqual(names, set(REFERENCE_DIRECT_VERSIONS))
self.assertTrue(all("==" not in line for line in requirements))
def test_reference_constraints_pin_only_direct_cross_platform_packages(
self,
) -> None:
self.assertEqual(_constraint_versions(), REFERENCE_DIRECT_VERSIONS)
self.assertTrue(
{
"httptools",
"pyyaml",
"uvloop",
"watchfiles",
"websockets",
}.isdisjoint(_constraint_versions())
)
@unittest.skipUnless(
os.getenv(VERIFY_ENVIRONMENT_VARIABLE, "").strip().lower()
in {"1", "true", "yes", "on"},
f"Set {VERIFY_ENVIRONMENT_VARIABLE}=1 to verify installed versions.",
)
def test_installed_environment_matches_reference_constraints(self) -> None:
self.assertEqual(sys.version_info[:3], (3, 12, 3))
installed = {
name: metadata.version(name)
for name in REFERENCE_DIRECT_VERSIONS
}
self.assertEqual(installed, REFERENCE_DIRECT_VERSIONS)
if __name__ == "__main__":
unittest.main()
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
import unittest
from unittest.mock import patch
from app.simulation.benchmark_regression import (
DEFAULT_MANIFEST_PATH,
RegressionCaseRequest,
RegressionManifestError,
)
from app.simulation.max_step_matrix import main, run_max_step_matrix
def _fake_completed(
request: RegressionCaseRequest,
*,
prefix_bias: float = 0.0,
) -> dict[str, object]:
signal_times = [value for value in (0.04, 0.8) if value <= request.stop_time]
checkpoints = [
{
"requestedTime": checkpoint,
"actualTime": checkpoint,
"available": True,
"stateValues": {
"state.a": checkpoint + prefix_bias,
"state.b": 2.0 * checkpoint,
},
}
for checkpoint in request.checkpoint_times
]
event_trace = {
"signalEventTimes": signal_times,
"stateTransitionCount": 0,
"mechanicalTransitionTimes": [],
"mechanicalTransitionTimesAvailable": True,
}
return {
"outcome": "completed",
"orchestrationWallSeconds": 0.01,
"worker": {
"outcome": "completed",
"wallSeconds": 0.01,
"summary": {
"success": True,
"status": "completed",
"simulatedUntil": request.stop_time,
"checkpoints": checkpoints,
"physicalContract": {
"schemaVersion": 1,
"projectionCategories": ["state"],
"checkpoints": checkpoints,
"eventTrace": event_trace,
},
"eventTrace": event_trace,
"diagnostics": {
"pressureFlow": {"maxScaledResidual": 1.0e-12},
"integration": {
"totals": {
"nfev": int(100 * request.stop_time / request.max_step),
"njev": 4,
"nlu": 8,
"acceptedStepCount": int(
request.stop_time / request.max_step
),
"solverStartCount": len(signal_times) + 1,
"stateTransitionCount": 0,
"recoverableRetryCount": 0,
}
},
},
},
},
}
def _fake_failure(request: RegressionCaseRequest) -> dict[str, object]:
return {
"outcome": "failed",
"worker": {
"outcome": "failed",
"summary": {
"success": False,
"status": "failed",
"simulatedUntil": 0.75 * request.stop_time,
},
},
}
class MaxStepMatrixTests(unittest.TestCase):
def test_single_cell_passes_when_no_comparison_is_required(self) -> None:
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.02,),
expected_projection_count=2,
case_executor=_fake_completed,
)
comparisons = report["comparisons"]
self.assertEqual(comparisons["sameHorizonAcrossMaxSteps"], [])
self.assertEqual(comparisons["sameMaxStepAcrossHorizons"], [])
self.assertEqual(comparisons["evaluatedCount"], 0)
self.assertTrue(comparisons["passed"])
self.assertTrue(report["acceptance"]["passed"])
with patch(
"app.simulation.max_step_matrix.run_max_step_matrix",
return_value=report,
), redirect_stdout(StringIO()):
exit_code = main(["--horizon", "1s", "--max-step", "0.02"])
self.assertEqual(exit_code, 0)
def test_multiple_cells_still_fail_when_comparison_is_unavailable(self) -> None:
def execute(request: RegressionCaseRequest) -> dict[str, object]:
if request.max_step == 0.05:
return _fake_failure(request)
return _fake_completed(request)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=execute,
)
comparisons = report["comparisons"]
records = comparisons["sameHorizonAcrossMaxSteps"]
self.assertEqual(len(records), 1)
self.assertFalse(records[0]["evaluated"])
self.assertEqual(records[0]["reason"], "oneOrBothCasesDidNotComplete")
self.assertFalse(comparisons["passed"])
self.assertFalse(report["acceptance"]["passed"])
with patch(
"app.simulation.max_step_matrix.run_max_step_matrix",
return_value=report,
), redirect_stdout(StringIO()):
exit_code = main(
[
"--horizon",
"1s",
"--max-step",
"0.01",
"--max-step",
"0.05",
]
)
self.assertEqual(exit_code, 1)
def test_custom_horizon_and_common_prefix_matrix(self) -> None:
source = Path("tests/data/test-mql-8.xml")
source_before = source.read_bytes()
requests: list[RegressionCaseRequest] = []
def execute(request: RegressionCaseRequest) -> dict[str, object]:
requests.append(request)
return _fake_completed(request)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01, 0.05),
additional_checkpoint_times=(1.0885267285, 1.0997760276),
expected_projection_count=2,
case_executor=execute,
)
self.assertEqual(source.read_bytes(), source_before)
self.assertEqual(len(requests), 4)
self.assertEqual(
[(request.stop_time, request.max_step) for request in requests],
[(1.0, 0.01), (1.0, 0.05), (2.0, 0.01), (2.0, 0.05)],
)
custom_requests = [request for request in requests if request.stop_time == 2.0]
self.assertEqual(
custom_requests[0].checkpoint_times,
(0.0, 0.04, 0.8, 1.0, 1.0885267285, 1.0997760276, 2.0),
)
self.assertEqual(
report["configuration"]["horizons"][1]["horizonCaseId"],
"2s-custom",
)
self.assertEqual(
len(report["comparisons"]["sameHorizonAcrossMaxSteps"]), 2
)
self.assertEqual(
len(report["comparisons"]["sameMaxStepAcrossHorizons"]), 2
)
self.assertTrue(report["comparisons"]["passed"])
self.assertTrue(report["acceptance"]["passed"])
def test_failed_tier_defers_every_later_cell(self) -> None:
requests: list[RegressionCaseRequest] = []
def execute(request: RegressionCaseRequest) -> dict[str, object]:
requests.append(request)
if request.max_step == 0.05:
return _fake_failure(request)
return _fake_completed(request)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", "5s", "10s"),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=execute,
)
self.assertEqual(len(requests), 2)
self.assertEqual(report["acceptance"]["deferredCellCount"], 4)
self.assertEqual(
[case["outcome"] for case in report["cases"][2:]],
["deferred"] * 4,
)
self.assertEqual(
report["tierDecisions"][1]["reason"], "previousTierDidNotPass"
)
def test_tstop_dependent_prefix_is_reported(self) -> None:
def execute(request: RegressionCaseRequest) -> dict[str, object]:
return _fake_completed(
request,
prefix_bias=0.1 if request.stop_time > 1.0 else 0.0,
)
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01, 0.05),
expected_projection_count=2,
stop_after_failed_tier=False,
case_executor=execute,
)
prefix_comparisons = report["comparisons"][
"sameMaxStepAcrossHorizons"
]
self.assertEqual(len(prefix_comparisons), 2)
self.assertTrue(all(item["evaluated"] for item in prefix_comparisons))
self.assertTrue(all(not item["passed"] for item in prefix_comparisons))
self.assertGreater(
prefix_comparisons[0]["stateProjection"]["valueMismatchCount"], 0
)
def test_horizons_must_increase_and_timeout_override_is_bounded(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError, "strictly increasing"
):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=(2.0, "1s"),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=_fake_completed,
)
with self.assertRaisesRegex(RegressionManifestError, "must exceed"):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=(2.0,),
max_steps=(0.01, 0.05),
soft_timeout_seconds=20.0,
hard_timeout_seconds=10.0,
expected_projection_count=2,
case_executor=_fake_completed,
)
if __name__ == "__main__":
unittest.main()
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import unittest
from app.main import compile_system_xml_network
from app.simulation.benchmark_regression import (
load_regression_manifest,
run_regression_suite,
source_simulation_config,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
MANIFEST_PATH = Path(
"tests/baselines/simulation/test_mql_full_branches/manifest.json"
)
LONG_RUN_ENVIRONMENT = "RUN_TEST_MQL_FULL_BRANCHES_LONG_REGRESSION"
class MqlFullBranchesStaticRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.manifest = load_regression_manifest(MANIFEST_PATH)
cls.source_path = Path(cls.manifest["_sourcePath"])
cls.source_payload = cls.source_path.read_bytes()
cls.validation = validate_system_xml_document(cls.source_payload)
def test_authoritative_xml_hash_and_simulation_settings_are_fixed(self) -> None:
source = self.manifest["source"]
self.assertEqual(
hashlib.sha256(self.source_payload).hexdigest(), source["sha256"]
)
self.assertEqual(len(self.source_payload), source["bytes"])
self.assertEqual(
source_simulation_config(self.source_payload), source["simulation"]
)
def test_structure_snapshot_covers_the_historical_solver_shape(self) -> None:
self.assertTrue(self.validation.valid, self.validation.as_dict())
assert self.validation.document is not None
network = compile_system_xml_network(self.validation.document)
system = GenericFluidSystem(network)
expected = self.manifest["structure"]
pressure_flow = network.pressure_flow_structure_dict()
causal_execution = system.pressure_flow_solver.causal_execution_diagnostics()
jacobian = system.jacobian_sparsity_diagnostics()
self.assertEqual(len(network.components), expected["componentCount"])
self.assertEqual(len(network.connections), expected["connectionCount"])
self.assertEqual(
len(network.dynamic_components()), expected["dynamicComponentCount"]
)
self.assertEqual(
sum(component.state_size for component in network.dynamic_components()),
expected["stateCount"],
)
self.assertEqual(
len(network.result_variable_metadata()), expected["resultVariableCount"]
)
self.assertEqual(
pressure_flow["unknownCount"], expected["pressureFlowUnknownCount"]
)
self.assertEqual(
pressure_flow["equationCount"], expected["pressureFlowEquationCount"]
)
self.assertEqual(pressure_flow["isSquare"], expected["pressureFlowIsSquare"])
for key in (
"logicalEffortCoordinateCount",
"eliminatedEffortAliasCount",
"canonicalCoordinateCount",
"compatibilityScatterCount",
):
self.assertEqual(causal_execution[key], expected[key])
self.assertEqual(jacobian["nonzeroCount"], expected["jacobianNonzeroCount"])
self.assertEqual(
jacobian["colorGroupCount"], expected["jacobianColorGroupCount"]
)
self.assertEqual(
system.mechanical_state_reducer.has_state_events,
expected["hasMechanicalStateEvents"],
)
def test_both_horizons_have_the_expected_signal_schedule(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(
compile_system_xml_network(self.validation.document)
)
for case_id in self.manifest["sequence"]:
variant = self.manifest["variants"][case_id]
actual = system.signal_resolver.event_times(
0.0, float(variant["stopTime"])
)
self.assertEqual(actual, tuple(variant["expectedSignalEventTimes"]))
@unittest.skipUnless(
os.getenv(LONG_RUN_ENVIRONMENT, "").strip().lower() in {"1", "true", "yes"},
f"Set {LONG_RUN_ENVIRONMENT}=1 to run bounded 0.81/2.10 s integration.",
)
class MqlFullBranchesLongRegressionTests(unittest.TestCase):
def test_both_horizons_complete_within_their_safety_budgets(self) -> None:
report = run_regression_suite(MANIFEST_PATH, lane="solver-only")
outcomes = [case["outcome"] for case in report["cases"]]
self.assertEqual(outcomes, ["completed", "completed"], report["cases"])
if __name__ == "__main__":
unittest.main()
+267 -1
View File
@@ -6,6 +6,8 @@ from unittest.mock import patch
from app.main import compile_reactflow_network
from app.simulation.solvers.algebraic import (
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
)
from app.simulation.systems.generic import GenericFluidSystem
@@ -16,11 +18,275 @@ from tests.test_amesim_pnvo001_signal_xml import (
from tests.test_generic_system_xml_simulation import chain_project
def _system(project) -> GenericFluidSystem:
def _system(
project,
*,
executor_v2: bool | None = False,
coordinate_kernel: bool | None = None,
) -> GenericFluidSystem:
environment = {}
if executor_v2 is not None:
environment[CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE] = (
"1" if executor_v2 else "0"
)
if coordinate_kernel is not None:
environment[CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE] = (
"1" if coordinate_kernel else "0"
)
with patch.dict(
os.environ,
environment,
):
if executor_v2 is None:
os.environ.pop(CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, None)
if coordinate_kernel is None:
os.environ.pop(
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
None,
)
return GenericFluidSystem(compile_reactflow_network(project))
class PressureFlowCausalExecutionTests(unittest.TestCase):
def test_compiled_v2_is_enabled_by_default_and_can_be_disabled(self) -> None:
default = _system(zero_force_mass_project(), executor_v2=None)
disabled = _system(zero_force_mass_project(), executor_v2=False)
enabled = _system(zero_force_mass_project(), executor_v2=True)
self.assertTrue(
default.pressure_flow_solver.causal_execution_diagnostics()[
"executorV2Configured"
]
)
self.assertFalse(
disabled.pressure_flow_solver.causal_execution_diagnostics()[
"executorV2Configured"
]
)
self.assertTrue(
enabled.pressure_flow_solver.causal_execution_diagnostics()[
"executorV2Configured"
]
)
def test_coordinate_kernel_is_default_on_and_independently_disabled(
self,
) -> None:
default = _system(
zero_force_mass_project(),
executor_v2=True,
)
disabled = _system(
zero_force_mass_project(),
executor_v2=True,
coordinate_kernel=False,
)
default_diagnostics = (
default.pressure_flow_solver.causal_execution_diagnostics()
)
disabled_diagnostics = (
disabled.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(default_diagnostics["coordinateKernelConfigured"])
self.assertTrue(default_diagnostics["coordinateKernelEnabled"])
self.assertFalse(disabled_diagnostics["coordinateKernelConfigured"])
self.assertFalse(disabled_diagnostics["coordinateKernelEnabled"])
def test_compiled_v2_matches_v1_bitwise_without_target_names(self) -> None:
compiled = _system(
high_pressure_helium_step_project(),
executor_v2=True,
)
v1 = _system(high_pressure_helium_step_project())
compiled_state = compiled.initial_state_vector()
v1_state = v1.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
compiled.rhs(time, compiled_state),
v1.rhs(time, v1_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in compiled.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in v1.pressure_flow_solver.unknowns
),
)
diagnostics = (
compiled.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertGreater(diagnostics["executorV2FastSolveCount"], 0)
self.assertGreater(diagnostics["coordinateKernelFastSolveCount"], 0)
self.assertEqual(
diagnostics["compiledAssignmentCount"],
len(compiled.pressure_flow_solver.unknowns),
)
self.assertEqual(
diagnostics["executorV2RuntimeValidationFailureCount"],
0,
)
self.assertEqual(
diagnostics["canonicalCoordinateCount"],
diagnostics["logicalEffortCoordinateCount"]
+ diagnostics["compiledFlowAssignmentCount"],
)
self.assertEqual(
diagnostics["eliminatedEffortAliasCount"],
diagnostics["compiledEffortUnknownCount"]
- diagnostics["logicalEffortCoordinateCount"],
)
def test_compiled_v2_fast_solve_skips_legacy_seed_scan_and_scales(
self,
) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_solve_explicit_flow_unknowns",
wraps=solver._solve_explicit_flow_unknowns,
) as legacy_seed, patch.object(
solver,
"_pressure_flow_equation_values",
wraps=solver._pressure_flow_equation_values,
) as residuals, patch.object(
solver,
"_scales",
wraps=solver._scales,
) as scales, patch.object(
solver,
"_evaluate_explicit_flow_stage",
wraps=solver._evaluate_explicit_flow_stage,
) as allocating_stage:
diagnostics = solver.solve(effort_variables=("p",))
legacy_seed.assert_not_called()
residuals.assert_not_called()
scales.assert_not_called()
allocating_stage.assert_not_called()
self.assertIs(
diagnostics,
solver._causal_cached_fast_diagnostics,
)
self.assertTrue(diagnostics.causal_fast_path_used)
def test_compiled_v2_nonfinite_assignment_fuses_and_audits_same_solve(
self,
) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_execute_causal_coordinate_flow_plan",
return_value="nonFiniteCausalFlowAssignment",
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"nonFiniteCausalFlowAssignment",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_compiled_v2_rejects_nonfinite_external_mechanical_effort(
self,
) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
velocity = next(
unknown for unknown in solver.unknowns if unknown.variable == "v"
)
velocity.write(float("nan"))
with self.assertRaises(ValueError):
solver.solve(effort_variables=("p",))
execution = solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"nonFiniteCausalExternalEffort",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_compiled_v2_stage_error_fuses_to_existing_fallback(self) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_execute_causal_coordinate_flow_plan",
return_value="causalFlowEvaluationFailed:ValueError",
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertEqual(
execution["disabledReason"],
"causalFlowEvaluationFailed:ValueError",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
def test_compiled_v2_assignment_count_drift_fuses_to_fallback(self) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
system.rhs(0.0, state)
solver = system.pressure_flow_solver
with patch.object(
solver,
"_execute_causal_coordinate_flow_plan",
return_value="causalFlowAssignmentCountMismatch",
):
diagnostics = solver.solve(effort_variables=("p",))
self.assertTrue(diagnostics.success)
self.assertTrue(diagnostics.residual_verified_this_solve)
execution = solver.causal_execution_diagnostics()
self.assertEqual(
execution["disabledReason"],
"causalFlowAssignmentCountMismatch",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
def test_compiled_v2_keeps_the_sixty_four_solve_audit_boundary(self) -> None:
system = _system(zero_force_mass_project(), executor_v2=True)
state = system.initial_state_vector()
solver = system.pressure_flow_solver
system.rhs(0.0, state)
for _iteration in range(64):
system.rhs(0.0, state)
before = solver.causal_execution_diagnostics()
self.assertEqual(before["executorV2FastSolveCount"], 64)
self.assertEqual(before["fullResidualAuditCount"], 1)
system.rhs(0.0, state)
after = solver.causal_execution_diagnostics()
self.assertEqual(after["executorV2FastSolveCount"], 64)
self.assertEqual(after["fullResidualAuditCount"], 2)
def test_strict_causal_rhs_matches_environment_disabled_legacy_bitwise(
self,
) -> None:
+1 -2
View File
@@ -5,7 +5,6 @@ import unittest
from app.simulation.examples.testmodel.run import prepare_testmodel_run
from app.simulation.paths import (
PROJECT_ROOT,
SIMULATION_BASELINES_DIR,
SIMULATION_RUNS_DIR,
)
@@ -18,7 +17,7 @@ class SimulationPathTests(unittest.TestCase):
self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR)
def test_testmodel_baselines_live_with_the_test_assets(self) -> None:
baseline_dir = SIMULATION_BASELINES_DIR / "testmodel"
baseline_dir = PROJECT_ROOT / "tests" / "data" / "testmodel"
self.assertTrue((baseline_dir / "testmodel_primary_series.csv").is_file())
self.assertTrue(
+148
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from types import SimpleNamespace
import unittest
from unittest.mock import patch
@@ -13,6 +14,7 @@ from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.components.experimental.storage.tank import Tank
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import (
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
AlgebraicSolveDiagnostics,
PressureFlowSolver,
)
@@ -26,6 +28,7 @@ from app.simulation.systems.network import SimulationNetwork
def _pnl0002_solver(
*,
include_unselected_island: bool = False,
executor_v2: bool = False,
) -> tuple[PressureFlowSolver, AmesimPnl0002]:
medium = IdealGasMedium()
left = Cylinder("left", medium, V=0.1, p0=500_000.0)
@@ -51,12 +54,157 @@ def _pnl0002_solver(
for component in network.dynamic_components():
component.refresh_thermodynamic_ports()
with patch.dict(
os.environ,
{
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE: (
"1" if executor_v2 else "0"
)
},
):
solver = PressureFlowSolver(network)
solver.solve()
return solver, pipe
class StreamPressureBlockSolverTests(unittest.TestCase):
def test_compiled_v2_secondary_matches_v1_and_skips_seed_sets(self) -> None:
compiled_solver, _compiled_pipe = _pnl0002_solver(executor_v2=True)
v1_solver, _v1_pipe = _pnl0002_solver()
compiled = StreamPressureBlockSolver(compiled_solver, ("pipe",))
v1 = StreamPressureBlockSolver(v1_solver, ("pipe",))
compiled.solve(scale_context=compiled_solver.scale_context())
v1.solve(scale_context=v1_solver.scale_context())
with patch.object(
compiled,
"_seed_selected_blocks",
wraps=compiled._seed_selected_blocks,
) as legacy_seed, patch.object(
compiled_solver,
"_evaluate_explicit_flow_stage",
wraps=compiled_solver._evaluate_explicit_flow_stage,
) as allocating_stage:
compiled_result = compiled.solve(
scale_context=compiled_solver.scale_context()
)
v1.solve(scale_context=v1_solver.scale_context())
legacy_seed.assert_not_called()
allocating_stage.assert_not_called()
self.assertEqual(
tuple(unknown.read() for unknown in compiled_solver.unknowns),
tuple(unknown.read() for unknown in v1_solver.unknowns),
)
self.assertIs(
compiled_result.diagnostics[0],
compiled._causal_cached_fast_diagnostics,
)
execution = compiled.causal_execution_diagnostics()
self.assertEqual(execution["executorV2FastSolveCount"], 1)
self.assertEqual(execution["coordinateKernelFastSolveCount"], 1)
self.assertEqual(
execution["compiledAssignmentCount"],
len(compiled._selected_unknowns),
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 0)
def test_compiled_v2_secondary_failure_restores_before_legacy_seed(
self,
) -> None:
solver, _pipe = _pnl0002_solver(executor_v2=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.solve(scale_context=solver.scale_context())
expected = tuple(
unknown.read() for unknown in block_solver._selected_flow_unknowns
)
original_seed = block_solver._seed_selected_blocks
def one_nonfinite_assignment():
block_solver._selected_flow_unknowns[0].state.m_flow = float("nan")
return "nonFiniteCausalSecondaryFlowAssignment"
def checked_legacy_seed(entry_values):
self.assertEqual(
tuple(
unknown.read()
for unknown in block_solver._selected_flow_unknowns
),
expected,
)
return original_seed(entry_values)
with patch.object(
block_solver,
"_execute_compiled_secondary_flow_plan",
side_effect=one_nonfinite_assignment,
), patch.object(
block_solver,
"_seed_selected_blocks",
side_effect=checked_legacy_seed,
):
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
execution = block_solver.causal_execution_diagnostics()
self.assertFalse(execution["enabled"])
self.assertEqual(
execution["disabledReason"],
"nonFiniteCausalSecondaryFlowAssignment",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
self.assertEqual(execution["legacyFallbackCount"], 1)
def test_compiled_v2_secondary_base_error_restores_transaction(self) -> None:
class ForcedFatalError(BaseException):
pass
solver, _pipe = _pnl0002_solver(executor_v2=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.solve(scale_context=solver.scale_context())
expected = tuple(
unknown.read() for unknown in block_solver._selected_flow_unknowns
)
def broken_execution() -> None:
block_solver._selected_flow_unknowns[0].write(123_456.0)
raise ForcedFatalError("forced v2 interruption")
with patch.object(
block_solver,
"_execute_compiled_secondary_flow_plan",
side_effect=broken_execution,
):
with self.assertRaises(ForcedFatalError):
block_solver.solve(scale_context=solver.scale_context())
self.assertEqual(
tuple(
unknown.read()
for unknown in block_solver._selected_flow_unknowns
),
expected,
)
def test_compiled_v2_secondary_assignment_count_drift_falls_back(self) -> None:
solver, _pipe = _pnl0002_solver(executor_v2=True)
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
block_solver.solve(scale_context=solver.scale_context())
with patch.object(
block_solver,
"_execute_compiled_secondary_flow_plan",
return_value="causalSecondaryFlowAssignmentCountMismatch",
):
result = block_solver.solve(scale_context=solver.scale_context())
self.assertFalse(result.used_global_fallback)
execution = block_solver.causal_execution_diagnostics()
self.assertEqual(
execution["disabledReason"],
"causalSecondaryFlowAssignmentCountMismatch",
)
self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1)
def test_pnl0002_uses_two_blocks_with_one_shared_component(self) -> None:
solver, pipe = _pnl0002_solver()
block_solver = StreamPressureBlockSolver(solver, ("pipe",))
@@ -65,6 +65,25 @@ class _PassThrough(AlgebraicComponent):
self.right.h_outflow = connected_h["left"]
class _ReferenceAwarePassThrough(_PassThrough):
def __init__(
self,
name: str,
update_log: list[str],
reference_log: list[str],
) -> None:
super().__init__(name, update_log)
self.reference_log = reference_log
self.flow_temperature_references: dict[str, float] = {}
def update_flow_temperature_references(
self,
connected_h: Mapping[str, float],
) -> None:
self.reference_log.append(self.name)
self.flow_temperature_references = dict(connected_h)
def _build_chain() -> tuple[
SimulationNetwork,
_CountingDynamicAnchor,
@@ -132,6 +151,53 @@ class StreamResolverExecutionPlanTests(unittest.TestCase):
["first", "second"] * diagnostics.iterations,
)
def test_non_dynamic_flow_reference_hook_does_not_repeat_stream_update(
self,
) -> None:
update_log: list[str] = []
reference_log: list[str] = []
left = _CountingDynamicAnchor(
"left_anchor",
enthalpy=100.0,
temperature_reference_h=1_100.0,
)
middle = _ReferenceAwarePassThrough(
"middle",
update_log,
reference_log,
)
right = _CountingDynamicAnchor(
"right_anchor",
enthalpy=400.0,
temperature_reference_h=1_400.0,
)
network = SimulationNetwork("flow-temperature-reference")
for component in (left, middle, right):
network.add_component(component)
network.connect("left_anchor", "port", "middle", "left")
network.connect("middle", "right", "right_anchor", "port")
resolver = StreamResolver(network)
diagnostics, _connected = resolver.solve()
stream_updates_before = tuple(update_log)
outflows_before = (middle.left.h_outflow, middle.right.h_outflow)
resolver.refresh_flow_temperature_references()
self.assertEqual(
stream_updates_before,
("middle",) * diagnostics.iterations,
)
self.assertEqual(tuple(update_log), stream_updates_before)
self.assertEqual(reference_log, ["middle"])
self.assertEqual(
middle.flow_temperature_references,
{"left": 1_100.0, "right": 1_400.0},
)
self.assertEqual(
(middle.left.h_outflow, middle.right.h_outflow),
outflows_before,
)
def test_precompiled_bindings_preserve_outputs_and_references(self) -> None:
default_network, default_left, default_first, default_second, default_right, _ = (
_build_chain()
+289
View File
@@ -0,0 +1,289 @@
from pathlib import Path
import os
import unittest
from unittest.mock import patch
import numpy as np
from scipy.optimize._numdiff import group_columns
from app.main import compile_system_xml_network
from app.simulation.solvers.jacobian import (
ExactColumnsUnavailable,
SparseSecantJacobian,
)
import app.simulation.solvers.tangent as tangent_module
from app.simulation.solvers.tangent import (
compile_supported_piston_tangent_provider,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import Endpoint
from app.system_xml import validate_system_xml_document
TARGET_XML = Path("tests/data/test-mql-8.xml")
EXPECTED_COLUMNS = tuple(range(104, 120))
EXPECTED_BRANCH_NAMES = (
(
"amesim_mecmas21_1",
"amesim_pnrp17_1",
"amesim_pnch012_15",
"amesim_pnl0001_13",
"amesim_lstp00a_1",
),
(
"amesim_mecmas21_2",
"amesim_pnrp17_2",
"amesim_pnch012_14",
"amesim_pnl0001_14",
"amesim_lstp00a_2",
),
(
"amesim_mecmas21_3",
"amesim_pnrp17_3",
"amesim_pnch012_13",
"amesim_pnl0001_15",
"amesim_lstp00a_3",
),
(
"amesim_mecmas21_4",
"amesim_pnrp17_4",
"amesim_pnch012_12",
"amesim_pnl0001_16",
"amesim_lstp00a_4",
),
(
"amesim_mecmas21_5",
"amesim_pnrp17_5",
"amesim_pnch012_11",
"amesim_pnl0001_17",
"amesim_lstp00a_5",
),
(
"amesim_mecmas21_6",
"amesim_pnrp17_6",
"amesim_pnch012_10",
"amesim_pnl0001_18",
"amesim_lstp00a_6",
),
(
"amesim_mecmas21_7",
"amesim_pnrp17_7",
"amesim_pnch012_9",
"amesim_pnl0001_19",
"amesim_lstp00a_7",
),
(
"amesim_mecmas21_8",
"amesim_pnrp17_8",
"amesim_pnch012_8",
"amesim_pnl0001_20",
"amesim_lstp00a_8",
),
)
class SupportedPistonTangentCompilerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> 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"}):
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_discovers_all_branches_without_legacy_names_or_offsets(self) -> None:
with patch.object(
tangent_module,
"_TARGET_BRANCH_NAMES",
(("not", "a", "real", "branch", "name"),),
):
compilation = compile_supported_piston_tangent_provider(
self.system
)
self.assertTrue(compilation.eligible, compilation.reason)
self.assertEqual(compilation.columns, EXPECTED_COLUMNS)
self.assertEqual(compilation.reached_assignment_count, 84)
provider = compilation.provider
assert provider is not None
self.assertEqual(len(provider.branches), 8)
self.assertEqual(
tuple(
(
branch.mass.name,
branch.piston.name,
branch.chamber.name,
branch.pipe.name,
branch.contact.name,
)
for branch in provider.branches
),
EXPECTED_BRANCH_NAMES,
)
self.assertEqual(
{branch.chamber_connection_port for branch in provider.branches},
{"port_2"},
)
def test_exact_columns_reduce_seed_zero_pattern_to_36_colors(self) -> None:
compilation = compile_supported_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
provider = compilation.provider
assert provider is not None
pattern = self.system.jacobian_sparsity().tocsc().astype(bool)
columns_only = pattern.tolil(copy=True)
columns_only[:, list(compilation.columns)] = False
columns_only = columns_only.tocsc()
columns_only.eliminate_zeros()
active_columns = np.flatnonzero(
np.asarray(columns_only.getnnz(axis=0)).reshape(-1) > 0
)
groups = group_columns(
columns_only[:, active_columns],
order=0,
)
self.assertEqual(pattern.nnz, 3296)
self.assertEqual(columns_only.nnz, 2464)
self.assertEqual(len(active_columns), 108)
self.assertEqual(int(groups.max(initial=-1)) + 1, 36)
jacobian = SparseSecantJacobian(
lambda _time, state: np.zeros_like(state),
pattern,
1.0e-8,
exact_rows=self.system._exact_ode_jacobian_rows(),
exact_columns=(compilation.columns, provider),
max_consecutive_reuses=0,
)
diagnostics = jacobian.diagnostics()
self.assertEqual(diagnostics["originalColorGroupCount"], 52)
self.assertEqual(diagnostics["remainingColorGroupCount"], 36)
self.assertEqual(diagnostics["exactColumnCount"], 16)
self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 108)
self.assertEqual(
jacobian._remaining_finite_difference_sparsity.nnz,
2176,
)
def test_partial_unsupported_branch_fails_with_stable_reason(self) -> None:
piston_endpoint = Endpoint("amesim_pnrp17_1", "port_5")
connection_index, connection = next(
(index, connection)
for index, connection in enumerate(self.system.network.connections)
if piston_endpoint in connection.endpoints
)
del self.system.network.connections[connection_index]
try:
compilation = compile_supported_piston_tangent_provider(
self.system
)
finally:
self.system.network.connections.insert(
connection_index,
connection,
)
self.assertFalse(compilation.eligible)
self.assertEqual(
compilation.reason,
"unsupportedPistonBranchTopology:contact",
)
def test_initial_contact_boundary_requests_full_numeric_columns(self) -> None:
compilation = compile_supported_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
provider = compilation.provider
assert provider is not None
connected_h, _derivative = self._closed_primal(
self.initial_state.copy()
)
provider.request_primal_capture()
provider.record_primal(
0.0,
self.initial_state,
connected_h,
)
with self.assertRaises(ExactColumnsUnavailable) as captured:
provider(
0.0,
self.initial_state.copy(),
compilation.columns,
)
self.assertEqual(
captured.exception.reason,
"contactMode:contact_mode_boundary",
)
def test_smooth_sixteen_columns_match_centered_full_rhs(self) -> None:
compilation = compile_supported_piston_tangent_provider(self.system)
self.assertTrue(compilation.eligible, compilation.reason)
provider = compilation.provider
assert provider is not None
state = self.initial_state.copy()
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=3.0e-6,
atol=2.0e-5,
)
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import unittest
from app.main import compile_system_xml_network
from app.simulation.benchmark_regression import (
DEFAULT_MANIFEST_PATH,
evaluate_regression_golden,
load_regression_golden,
load_regression_manifest,
run_regression_suite,
source_simulation_config,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
LONG_RUN_ENVIRONMENT = "RUN_TEST_MQL_8_LONG_REGRESSION"
class TestMql8StaticRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
cls.source_path = Path(cls.manifest["_sourcePath"])
cls.source_payload = cls.source_path.read_bytes()
cls.validation = validate_system_xml_document(cls.source_payload)
def test_authoritative_xml_hash_and_simulation_settings_are_fixed(self) -> None:
source = self.manifest["source"]
self.assertEqual(
hashlib.sha256(self.source_payload).hexdigest(), source["sha256"]
)
self.assertEqual(len(self.source_payload), source["bytes"])
self.assertEqual(
source_simulation_config(self.source_payload), source["simulation"]
)
self.assertEqual(self.source_path.name, "test-mql-8.xml")
companion = source["companionProject"]
companion_path = Path(self.manifest["_companionPath"])
companion_payload = companion_path.read_bytes()
companion_document = json.loads(companion_payload)
self.assertEqual(companion_path.name, "test-mql-8.json")
self.assertEqual(
hashlib.sha256(companion_payload).hexdigest(), companion["sha256"]
)
self.assertEqual(len(companion_payload), companion["bytes"])
self.assertFalse(companion["executionInput"])
self.assertEqual(
companion_document["simulation"],
{
"t_start": source["simulation"]["tStart"],
"t_stop": source["simulation"]["tStop"],
"step": source["simulation"]["sampleStep"],
"max_step": source["simulation"]["maxStep"],
"method": source["simulation"]["method"],
},
)
def test_authoritative_xml_validates_and_matches_structure_snapshot(self) -> None:
self.assertTrue(self.validation.valid, self.validation.as_dict())
assert self.validation.document is not None
network = compile_system_xml_network(self.validation.document)
system = GenericFluidSystem(network)
expected = self.manifest["structure"]
pressure_flow = network.pressure_flow_structure_dict()
causal_execution = system.pressure_flow_solver.causal_execution_diagnostics()
jacobian = system.jacobian_sparsity_diagnostics()
self.assertEqual(len(network.components), expected["componentCount"])
self.assertEqual(len(network.connections), expected["connectionCount"])
self.assertEqual(
len(network.dynamic_components()), expected["dynamicComponentCount"]
)
self.assertEqual(
sum(component.state_size for component in network.dynamic_components()),
expected["stateCount"],
)
self.assertEqual(
len(network.result_variable_metadata()), expected["resultVariableCount"]
)
self.assertEqual(
pressure_flow["unknownCount"], expected["pressureFlowUnknownCount"]
)
self.assertEqual(
pressure_flow["equationCount"], expected["pressureFlowEquationCount"]
)
self.assertEqual(pressure_flow["isSquare"], expected["pressureFlowIsSquare"])
for key in (
"logicalEffortCoordinateCount",
"eliminatedEffortAliasCount",
"canonicalCoordinateCount",
"compatibilityScatterCount",
):
self.assertEqual(causal_execution[key], expected[key])
self.assertEqual(
jacobian["nonzeroCount"], expected["jacobianNonzeroCount"]
)
self.assertEqual(
jacobian["colorGroupCount"], expected["jacobianColorGroupCount"]
)
self.assertEqual(
system.mechanical_state_reducer.has_state_events,
expected["hasMechanicalStateEvents"],
)
def test_signal_event_schedule_is_fixed_for_every_horizon(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(
compile_system_xml_network(self.validation.document)
)
for case_id in self.manifest["sequence"]:
variant = self.manifest["variants"][case_id]
actual = system.signal_resolver.event_times(
0.0, float(variant["stopTime"])
)
self.assertEqual(
actual,
tuple(variant["expectedSignalEventTimes"]),
case_id,
)
def test_approved_production_golden_replays_its_source_report(self) -> None:
repository_root = Path(self.manifest["_repositoryRoot"])
reference = self.manifest["variants"]["0.2s"]["goldens"]["production"]
golden = load_regression_golden(
repository_root / reference["path"],
expected_sha256=reference["sha256"],
repository_root=repository_root,
)
source_report = golden["provenance"]["sourceReport"]
report = json.loads(
(repository_root / source_report["path"]).read_text(encoding="utf-8")
)
case = next(item for item in report["cases"] if item["caseId"] == "0.2s")
audit = evaluate_regression_golden(case["worker"]["summary"], golden)
self.assertTrue(audit["passed"], audit)
self.assertEqual(audit["comparedValueCount"], 402)
self.assertEqual(audit["maxAbsoluteError"], 0.0)
self.assertEqual(audit["maxToleranceRatio"], 0.0)
def test_extension_decision_is_bound_to_the_current_report_and_budget(self) -> None:
repository_root = Path(self.manifest["_repositoryRoot"])
runs = repository_root / "tests/baselines/simulation/test_mql_8/runs"
decision = json.loads(
(runs / "2026-08-17-production-v2-extension-decision.json").read_text(
encoding="utf-8"
)
)
report_path = runs / decision["sourceReport"]
report_payload = report_path.read_bytes()
report = json.loads(report_payload)
source_case = next(
item for item in report["cases"] if item["caseId"] == "0.2s"
)
first_decision = decision["decisions"][0]
self.assertEqual(
decision["sourceXmlSha256"], self.manifest["source"]["sha256"]
)
self.assertEqual(
hashlib.sha256(report_payload).hexdigest(),
decision["sourceReportSha256"],
)
self.assertTrue(source_case["acceptance"]["passed"])
self.assertEqual(
decision["observedCase"]["workerWallSeconds"],
source_case["worker"]["wallSeconds"],
)
expected_prediction = (
source_case["worker"]["wallSeconds"]
* self.manifest["variants"]["1s"]["stopTime"]
/ source_case["stopTime"]
* self.manifest["execution"]["predictionSafetyFactor"]
)
self.assertAlmostEqual(
first_decision["predictedWallSeconds"], expected_prediction
)
self.assertGreater(
first_decision["predictedWallSeconds"],
first_decision["softTimeoutSeconds"],
)
self.assertEqual(first_decision["outcome"], "deferred")
self.assertTrue(decision["simulationWasNotStartedForDeferredCases"])
def test_periodic_main_lane_exercises_the_approved_production_golden(self) -> None:
workflow = (
Path(self.manifest["_repositoryRoot"])
/ ".github/workflows/solver-regression.yml"
).read_text(encoding="utf-8")
self.assertIn("default: production", workflow)
self.assertIn("inputs.lane || 'production'", workflow)
@unittest.skipUnless(
os.getenv(LONG_RUN_ENVIRONMENT, "").strip().lower() in {"1", "true", "yes"},
f"Set {LONG_RUN_ENVIRONMENT}=1 to run bounded 0.01/0.2/1/5/10 s integration.",
)
class TestMql8ProgressiveLongRegressionTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH)
def test_all_horizons_complete_or_stop_at_the_first_bounded_failure(self) -> None:
lane = os.getenv("TEST_MQL_8_REGRESSION_LANE", "production")
report = run_regression_suite(DEFAULT_MANIFEST_PATH, lane=lane)
outcomes = [case["outcome"] for case in report["cases"]]
self.assertEqual(outcomes[0], "completed", report["cases"][0])
if "deferred" in outcomes:
first_deferred = outcomes.index("deferred")
self.assertTrue(
all(outcome == "deferred" for outcome in outcomes[first_deferred:])
)
predecessor = report["cases"][first_deferred - 1]
self.fail(
"Progressive run stopped within its configured safety budget; "
f"optimize before resuming. Predecessor: {predecessor}"
)
self.assertEqual(
outcomes,
["completed"] * len(self.manifest["sequence"]),
report["cases"],
)
if __name__ == "__main__":
unittest.main()
+339
View File
@@ -0,0 +1,339 @@
from __future__ import annotations
from time import perf_counter
import unittest
from unittest.mock import patch
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.pipes import AmesimPnl00r
from app.simulation.components.experimental.storage.cylinder import Cylinder
from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.core.medium import IdealGasMedium
from app.simulation.solvers.algebraic import (
AlgebraicSolveDiagnostics,
AlgebraicSolveError,
)
from app.simulation.solvers.algebraic_blocks import StreamBlockSolveResult
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.solvers.stream import StreamSolveDiagnostics, StreamSolveError
from app.simulation.solvers.thermofluid import (
ThermofluidClosureError,
ThermofluidTransactionPlan,
)
from app.simulation.systems.generic import GenericFluidSystem
from app.simulation.systems.network import SimulationNetwork
def _pnl00r_system() -> tuple[GenericFluidSystem, AmesimPnl00r]:
medium = IdealGasMedium()
source = Cylinder(
"source",
medium,
V=0.02,
p0=500_000.0,
T0=310.0,
)
resistance = AmesimPnl00r("resistance", medium)
resistance._causal_test_seed = {"accepted": 7.0}
plug = AmesimPnpl01("plug")
network = SimulationNetwork("thermofluid-rollback")
for component in (source, resistance, plug):
network.add_component(component)
network.connect("source", "port_b", "resistance", "port_1")
network.connect("resistance", "port_2", "plug", "port_1")
return GenericFluidSystem(network), resistance
def _physical_values(system: GenericFluidSystem) -> tuple[float, ...]:
return tuple(
float(getattr(binding.state, binding.variable))
for binding in system._thermofluid_transaction_plan.port_value_bindings
)
class ThermofluidRecoveryTests(unittest.TestCase):
def test_nonconvergence_is_recoverable_diagnostic_and_fully_restored(self) -> None:
system, resistance = _pnl00r_system()
state = system.consistent_initial_state_vector()
system.rhs(0.01, state)
secondary = system._thermofluid_closure_plan.secondary_block_solvers[0]
target = resistance.port_1
expected_ports = _physical_values(system)
expected_connected_h = dict(resistance._connected_h)
expected_causal_seed = dict(resistance._causal_test_seed)
expected_owner_diagnostics = tuple(
owner.last_diagnostics
for owner in system._thermofluid_transaction_plan.diagnostic_owners
)
expected_last_algebraic = system._last_algebraic_diagnostics
expected_last_scope = system._last_algebraic_scope
expected_last_success = (
system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"]
)
fake_diagnostics = AlgebraicSolveDiagnostics(
success=True,
message="forced divergent thermofluid pass",
evaluations=0,
pressure_scale=1.0,
flow_scale=1.0,
max_scaled_residual=0.0,
max_raw_residual=0.0,
)
call_count = 0
def divergent_solve(*, scale_context=None):
del scale_context
nonlocal call_count
call_count += 1
target.m_flow += 1.0
resistance._connected_h = {
"port_1": -float(call_count),
"port_2": -2.0 * float(call_count),
}
resistance._causal_test_seed["polluted"] = float(call_count)
return StreamBlockSolveResult(
diagnostics=(fake_diagnostics,),
scopes=(
system._thermofluid_closure_plan.secondary_component_groups[0],
),
used_global_fallback=False,
)
with patch.object(secondary, "solve", side_effect=divergent_solve):
with self.assertRaises(ThermofluidClosureError) as raised:
system.rhs(0.125, state)
error = raised.exception
self.assertIsInstance(error, RecoverableTrialStateError)
failure = error.diagnostics
self.assertEqual(call_count, 25)
self.assertEqual(failure.failed_rhs_time, 0.125)
self.assertEqual(failure.iterations, 25)
self.assertEqual(failure.failure_count, 1)
self.assertEqual(len(failure.delta_tail), 8)
self.assertEqual(
[item.iteration for item in failure.delta_tail],
list(range(18, 26)),
)
self.assertEqual(failure.max_delta, 1.0)
self.assertEqual(failure.scale, 25.0)
self.assertEqual(failure.tolerance, 25.0e-12)
self.assertIsNotNone(failure.worst_port)
assert failure.worst_port is not None
self.assertEqual(failure.worst_port.component, "resistance")
self.assertEqual(failure.worst_port.port, "port_1")
self.assertEqual(failure.worst_port.signed_delta, 1.0)
self.assertEqual(_physical_values(system), expected_ports)
self.assertEqual(resistance._connected_h, expected_connected_h)
self.assertEqual(resistance._causal_test_seed, expected_causal_seed)
self.assertEqual(
tuple(
owner.last_diagnostics
for owner in system._thermofluid_transaction_plan.diagnostic_owners
),
expected_owner_diagnostics,
)
self.assertIs(system._last_algebraic_diagnostics, expected_last_algebraic)
self.assertEqual(system._last_algebraic_scope, expected_last_scope)
self.assertTrue(system.pressure_flow_solver._causal_audit_required)
self.assertTrue(secondary._causal_audit_required)
outcomes = system._thermofluid_closure_diagnostics.as_dict()
self.assertEqual(outcomes["failureCount"], 1)
self.assertEqual(outcomes["lastSuccess"], expected_last_success)
self.assertEqual(outcomes["lastFailure"], failure.as_dict())
# A successful maintenance/postprocessing closure must not masquerade
# as the last successful integrator RHS.
system._close_current_state(0.5)
self.assertEqual(
system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"],
expected_last_success,
)
# The restored seed is safe to replay at the failed time.
retried = system.rhs(0.125, state)
self.assertEqual(len(retried), len(state))
outcomes = system._thermofluid_closure_diagnostics.as_dict()
self.assertEqual(outcomes["failureCount"], 1)
self.assertEqual(outcomes["lastFailure"], failure.as_dict())
self.assertEqual(outcomes["lastSuccess"]["rhsTime"], 0.125)
def test_simulation_result_exposes_closure_and_transaction_diagnostics(self) -> None:
system, _resistance = _pnl00r_system()
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=1.0e-4,
method="RK45",
max_step=1.0e-4,
),
sample_step=1.0e-4,
)
self.assertTrue(result.success)
closure = result.diagnostics["stream"]["thermofluidClosure"]
self.assertEqual(closure["failureCount"], 0)
self.assertIsNone(closure["lastFailure"])
self.assertIsNotNone(closure["lastSuccess"])
self.assertEqual(
closure["transaction"]["physicalPortValueSlotCount"],
20,
)
self.assertEqual(
closure["transaction"]["physicalFlowPortCount"],
4,
)
def test_eventless_generic_simulation_retries_a_recoverable_trial(self) -> None:
system, _resistance = _pnl00r_system()
self.assertFalse(system.mechanical_state_reducer.has_state_events)
self.assertEqual(system.signal_resolver.event_times(0.0, 1.0e-4), ())
original_rhs = system.rhs
failed_once = False
def fail_first_positive_trial(time, state):
nonlocal failed_once
if time > 0.0 and not failed_once:
failed_once = True
raise RecoverableTrialStateError("forced recoverable trial")
return original_rhs(time, state)
with patch.object(system, "rhs", side_effect=fail_first_positive_trial):
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=1.0e-4,
method="RK45",
max_step=1.0e-4,
),
sample_step=1.0e-4,
)
self.assertTrue(failed_once)
self.assertTrue(result.success)
totals = result.diagnostics["integration"]["totals"]
self.assertEqual(totals["recoverableRetryCount"], 1)
self.assertEqual(totals["stateTransitionCount"], 0)
segment = result.diagnostics["integration"]["segments"][0]
self.assertEqual(len(segment["recoverableRetries"]), 1)
self.assertEqual(
segment["recoverableRetries"][0]["reason"],
"forced recoverable trial",
)
def test_other_closure_failures_restore_but_remain_nonrecoverable(self) -> None:
fake_algebraic_diagnostics = AlgebraicSolveDiagnostics(
success=False,
message="forced algebraic failure",
evaluations=1,
pressure_scale=1.0,
flow_scale=1.0,
max_scaled_residual=2.0,
max_raw_residual=2.0,
)
for failure_kind in ("stream", "secondaryAlgebraic"):
with self.subTest(failure_kind=failure_kind):
system, resistance = _pnl00r_system()
state = system.consistent_initial_state_vector()
system.rhs(0.01, state)
expected_ports = _physical_values(system)
expected_cache = dict(resistance._connected_h)
expected_success = (
system._thermofluid_closure_diagnostics.as_dict()[
"lastSuccess"
]
)
def pollute() -> None:
resistance.port_1.p = -9.0
resistance.port_1.m_flow = 99.0
resistance.port_1.h_outflow = -999.0
resistance._connected_h = {
"port_1": -1.0,
"port_2": -2.0,
}
if failure_kind == "stream":
def failed_stream(*, dynamic_ports_are_current=False):
del dynamic_ports_are_current
pollute()
diagnostics = StreamSolveDiagnostics(
converged=False,
iterations=100,
max_delta=1.0,
)
raise StreamSolveError("forced stream failure", diagnostics)
context = patch.object(
system.stream_resolver,
"solve",
side_effect=failed_stream,
)
expected_error = StreamSolveError
else:
secondary = (
system._thermofluid_closure_plan.secondary_block_solvers[0]
)
def failed_secondary(*, scale_context=None):
del scale_context
pollute()
raise AlgebraicSolveError(
"forced secondary failure",
fake_algebraic_diagnostics,
scope_kind="physicalIsland",
)
context = patch.object(
secondary,
"solve",
side_effect=failed_secondary,
)
expected_error = AlgebraicSolveError
with context:
with self.assertRaises(expected_error) as raised:
system.rhs(0.25, state)
self.assertNotIsInstance(
raised.exception,
RecoverableTrialStateError,
)
self.assertEqual(_physical_values(system), expected_ports)
self.assertEqual(resistance._connected_h, expected_cache)
outcomes = system._thermofluid_closure_diagnostics.as_dict()
self.assertEqual(outcomes["failureCount"], 0)
self.assertIsNone(outcomes["lastFailure"])
self.assertEqual(outcomes["lastSuccess"], expected_success)
def test_transaction_microbenchmark_stays_scoped_to_numeric_slots(self) -> None:
medium = IdealGasMedium()
network = SimulationNetwork("transaction-microbenchmark")
for index in range(120):
network.add_component(AmesimPnl00r(f"resistance_{index}", medium))
plan = ThermofluidTransactionPlan.compile(network)
diagnostics = plan.diagnostics()
self.assertEqual(diagnostics["physicalPortValueSlotCount"], 1_200)
self.assertEqual(diagnostics["streamAndCausalCacheSlotCount"], 120)
for _ in range(5):
plan.capture().restore()
repetitions = 200
started = perf_counter()
for _ in range(repetitions):
plan.capture().restore()
seconds_per_transaction = (perf_counter() - started) / repetitions
# This deliberately generous guard detects accidental full component/
# network deepcopy while remaining stable on slow CI workers.
self.assertLess(seconds_per_transaction, 0.01)
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -254,8 +254,11 @@ class ThreePistonTangentCompilerTests(unittest.TestCase):
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)
# Generic wiring uses topology discovery rather than the legacy
# three-name wrapper. This fixture contains one additional supported
# branch, so all four branches are compiled automatically.
self.assertEqual(runtime["remainingColorGroupCount"], 24)
self.assertEqual(runtime["exactColumnCount"], 8)
def test_generic_simulation_ineligible_path_uses_native_scipy(self) -> None:
report = validate_system_xml_document(TARGET_XML.read_bytes())