整合求解器活动监控与步长回归证据

同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。

纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
This commit is contained in:
lujingze committed 2026-08-19 16:24:31 +00:00
1 parent c19cf77aee
commit e18399c022
46 files changed
+181589 -170

No files matched your search

+55 -21
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
from contextlib import asynccontextmanager
import csv
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timezone
import io
import json
@@ -24,6 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError
from app.simulation.performance import performance_span, profile_phase, profile_run
from app.simulation.property_cache import property_cache_run
from app.simulation.solvers.solver import SolverActivityTracker
from app.system_xml import (
SystemXmlDocument,
SystemXmlValidationReport,
@@ -86,6 +87,9 @@ SimulationTaskStatus = Literal[
class SimulationTaskRecord:
simulation_id: str
cancel_event: threading.Event
activity_tracker: SolverActivityTracker = field(
default_factory=SolverActivityTracker
)
status: SimulationTaskStatus = "queued"
cancel_reason: SimulationCancelReason | None = None
result: dict[str, object] | None = None
@@ -574,6 +578,7 @@ def _simulation_task_snapshot(task: SimulationTaskRecord) -> dict[str, object]:
"cancelReason": task.cancel_reason,
"result": task.result,
"error": task.error,
**task.activity_tracker.snapshot().as_dict(),
}
@@ -681,6 +686,7 @@ def run_system_xml_simulation(
xml_bytes: bytes,
progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> dict[str, object]:
with property_cache_run() as property_cache:
with profile_run() as trace:
@@ -688,6 +694,7 @@ def run_system_xml_simulation(
xml_bytes,
progress_callback,
cancel_check,
activity_tracker,
)
performance = trace.snapshot()
@@ -712,6 +719,7 @@ def _run_system_xml_simulation_profiled(
xml_bytes: bytes,
progress_callback: SimulationProgressEmitter | None = None,
cancel_check: Callable[[], bool] | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> dict[str, object]:
from app.simulation.solvers.algebraic import AlgebraicSolveError
from app.simulation.solvers.solver import SolveIVPConfig
@@ -774,6 +782,7 @@ def _run_system_xml_simulation_profiled(
sample_step=document.simulation.sample_step,
progress_callback=report_system_progress,
cancel_check=cancel_check,
activity_tracker=activity_tracker,
)
except SimulationPreparationError as exc:
raise HTTPException(
@@ -862,6 +871,9 @@ def simulation_event_stream(
latest_message = "正在等待仿真任务启动"
latest_simulated_time: float | None = None
latest_total_time: float | None = None
activity_tracker = (
task.activity_tracker if task is not None else SolverActivityTracker()
)
def emit_progress(
progress: int,
@@ -889,6 +901,7 @@ def simulation_event_stream(
event["simulatedTime"] = latest_simulated_time
if latest_total_time is not None:
event["totalTime"] = latest_total_time
event.update(activity_tracker.snapshot().as_dict())
events.put(event)
def worker() -> None:
@@ -899,28 +912,44 @@ def simulation_event_stream(
xml_bytes,
emit_progress,
task.cancel_event.is_set if task is not None else None,
activity_tracker,
)
if task is not None:
result = _mark_simulation_task_result(task, result)
result_status = str(result.get("status", "completed"))
final_simulated_time = result.get("simulatedUntil")
final_activity_kind = (
"complete" if result_status == "completed" else result_status
)
if activity_tracker.snapshot().activity_kind != final_activity_kind:
activity_tracker.record_phase(
final_activity_kind,
(
float(final_simulated_time)
if isinstance(final_simulated_time, (int, float))
and isfinite(final_simulated_time)
else None
),
)
result_messages = {
"completed": "仿真完成",
"stopped": "仿真已由用户终止,已保留部分结果",
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
"failed": "仿真异常终止,已保留可用的部分结果",
}
events.put(
{
"event": "result",
"progress": 100 if result_status == "completed" else latest_progress,
"phase": result_status,
"message": result_messages.get(result_status, "仿真任务结束"),
"simulatedTime": result.get("simulatedUntil"),
"totalTime": result.get("requestedStopTime"),
"result": result,
}
)
result_event = {
"event": "result",
"progress": 100 if result_status == "completed" else latest_progress,
"phase": result_status,
"message": result_messages.get(result_status, "仿真任务结束"),
"simulatedTime": result.get("simulatedUntil"),
"totalTime": result.get("requestedStopTime"),
"result": result,
}
result_event.update(activity_tracker.snapshot().as_dict())
events.put(result_event)
except HTTPException as exc:
activity_tracker.record_phase("failed")
detail = exc.detail
message = (
str(detail.get("message", "仿真失败"))
@@ -935,10 +964,12 @@ def simulation_event_stream(
"message": message,
"detail": detail,
}
error_event.update(activity_tracker.snapshot().as_dict())
if task is not None:
_mark_simulation_task_error(task, error_event)
events.put(error_event)
except Exception as exc: # pragma: no cover - last-resort stream guard
activity_tracker.record_phase("failed")
error_event = {
"event": "error",
"progress": latest_progress,
@@ -947,6 +978,7 @@ def simulation_event_stream(
"message": "仿真服务发生未预期错误。",
"detail": str(exc),
}
error_event.update(activity_tracker.snapshot().as_dict())
if task is not None:
_mark_simulation_task_error(task, error_event)
events.put(error_event)
@@ -964,16 +996,18 @@ def simulation_event_stream(
try:
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
except queue.Empty:
heartbeat_event = {
"event": "progress",
"progress": latest_progress,
"phase": latest_phase,
"message": latest_message,
"heartbeat": True,
"simulatedTime": latest_simulated_time,
"totalTime": latest_total_time,
}
heartbeat_event.update(activity_tracker.snapshot().as_dict())
yield json.dumps(
{
"event": "progress",
"progress": latest_progress,
"phase": latest_phase,
"message": latest_message,
"heartbeat": True,
"simulatedTime": latest_simulated_time,
"totalTime": latest_total_time,
},
heartbeat_event,
ensure_ascii=False,
separators=(",", ":"),
) + "\n"
+9
View File
@@ -1038,9 +1038,18 @@ def runtime_snapshot() -> dict[str, object]:
"SIMULATION_CAUSAL_COORDINATE_KERNEL": os.getenv(
"SIMULATION_CAUSAL_COORDINATE_KERNEL", "1"
),
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": os.getenv(
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS", "1"
),
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": os.getenv(
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS", "1"
),
"SIMULATION_CAUSAL_FAST_PATH": os.getenv(
"SIMULATION_CAUSAL_FAST_PATH", "1"
),
"SIMULATION_MECHANICAL_ATOL_MODE": os.getenv(
"SIMULATION_MECHANICAL_ATOL_MODE", "legacy"
),
},
}
@@ -60,6 +60,9 @@ class AmesimPnor001(AlgebraicComponent):
MODEL_TYPE = "amesim_pnor001"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -465,6 +468,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
MODEL_TYPE = "amesim_pnvo001_fixed"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
@@ -900,6 +906,10 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
MODEL_TYPE = "amesim_pnvo001"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
# Repeat the exact-sum promise on this concrete subclass deliberately.
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
+106 -14
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from functools import lru_cache
from math import isclose, isfinite, log, log10, pi, sqrt, tanh
@@ -71,6 +71,47 @@ _PN2PIPEFR_TRANSITION_SHARPNESS = 8.37293695
_PN2PIPEFR_ANALYTIC_LAMINAR_MAX_REYNOLDS = 1000.0
def _pn2pipefr_friction_factor_with_precomputed_fully_rough(
reynolds_number: float,
*,
relative_roughness: float,
fully_rough: float | None,
) -> float:
"""Evaluate the built-in friction law with its rr-only term supplied."""
if reynolds_number <= 0.0:
return 64_000_000.0
laminar = 64.0 / reynolds_number
if reynolds_number <= _PN2PIPEFR_TRANSITION_START_REYNOLDS:
return laminar
# Keep this arithmetic in the same order as AmesimPnl00r.friction_factor.
# The specialized fixed-point path only moves the rr-only logarithm out of
# the iteration; subclasses continue to use the public virtual method.
smooth_turbulent = 1.0 / (
-1.8 * log10(6.9 / reynolds_number)
) ** 2
if relative_roughness <= 0.0:
turbulent = smooth_turbulent
else:
assert fully_rough is not None
roughness_reynolds = reynolds_number * relative_roughness
roughness_reynolds_squared = roughness_reynolds * roughness_reynolds
roughness_weight = roughness_reynolds_squared / (
roughness_reynolds_squared + 180.0 * 180.0
)
turbulent = smooth_turbulent + roughness_weight * (
fully_rough - smooth_turbulent
)
transition_coordinate = (
(reynolds_number - _PN2PIPEFR_TRANSITION_START_REYNOLDS)
/ _PN2PIPEFR_TRANSITION_SCALE_REYNOLDS
)
transition_power = transition_coordinate**_PN2PIPEFR_TRANSITION_SHARPNESS
transition_weight = transition_power / (1.0 + transition_power)
return laminar + transition_weight * (turbulent - laminar)
def _reported_friction_factor(value: float) -> float:
return min(float(value), _MAX_REPORTED_FRICTION_FACTOR)
@@ -123,6 +164,9 @@ class AmesimPnl00r(AlgebraicComponent):
MODEL_TYPE = "amesim_pnl00r"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -876,11 +920,11 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
value *= tanh(max(smoothing_argument, 0.0))
return value
pressure_ratio_flow_parameter = mass_flow_parameter(pressure_ratio)
if analytic_laminar:
flow_parameter = mass_flow_parameter(pressure_ratio)
viscosity = self._dynamic_viscosity(T_up)
laminar_mass_flow = (
self.area * p_up * flow_parameter
self.area * p_up * pressure_ratio_flow_parameter
) ** 2 / (16.0 * pi * viscosity * resistance_length * T_up)
if (
self.reynolds_number(laminar_mass_flow, T_up)
@@ -888,27 +932,58 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
):
return laminar_mass_flow
sqrt_temperature = sqrt(T_up)
compiled_reynolds = (
type(self).reynolds_number is AmesimPnl0001.reynolds_number
and type(self)._dynamic_viscosity is AmesimPnl0001._dynamic_viscosity
)
reynolds_denominator = (
pi * self.diam * self._dynamic_viscosity(T_up)
if max_iterations > 0 and compiled_reynolds
else None
)
compiled_friction_factor = type(self) in (
AmesimPnl00r,
AmesimPnl0001,
AmesimPnl0002,
)
fully_rough = (
1.0 / (-2.0 * log10(self.rr / 3.7)) ** 2
if max_iterations > 0
and compiled_friction_factor
and self.rr > 0.0
else None
)
def target_flow(mass_flow: float) -> float:
reynolds = self.reynolds_number(mass_flow, T_up)
friction = self.friction_factor(reynolds)
flow_coefficient = sqrt(
self.diam / (resistance_length * friction)
reynolds = (
4.0 * abs(mass_flow) / reynolds_denominator
if reynolds_denominator is not None
else self.reynolds_number(mass_flow, T_up)
)
friction = (
_pn2pipefr_friction_factor_with_precomputed_fully_rough(
reynolds,
relative_roughness=self.rr,
fully_rough=fully_rough,
)
if compiled_friction_factor
else self.friction_factor(reynolds)
)
return (
flow_coefficient
sqrt(self.diam / (resistance_length * friction))
* self.area
* p_up
* mass_flow_parameter(pressure_ratio)
/ sqrt(T_up)
* pressure_ratio_flow_parameter
/ sqrt_temperature
)
flow_coefficient = sqrt(self.diam / (resistance_length * 0.02))
magnitude = (
flow_coefficient
sqrt(self.diam / (resistance_length * 0.02))
* self.area
* p_up
* mass_flow_parameter(pressure_ratio)
/ sqrt(T_up)
* pressure_ratio_flow_parameter
/ sqrt_temperature
)
for _iteration in range(max_iterations):
next_magnitude = target_flow(magnitude)
@@ -1069,6 +1144,23 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
self.port_1.m_flow - self.mass_flow(self.port_1.p, props.p, props.T),
)
def pressure_flow_equation_value_readers(
self,
) -> Mapping[str, Callable[[], float]]:
"""Expose the independently evaluable state-pressure residual."""
def pressure_state_residual() -> float:
props = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.volume,
)
return self.port_2.p - props.p
return {
f"{self.name}:port_2_pressure_state": pressure_state_residual,
}
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume)
return (
@@ -163,6 +163,9 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
MODEL_TYPE = "amesim_pn3node2"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -200,6 +203,9 @@ class AmesimP4Node2(_AmesimPneumaticNode):
MODEL_TYPE = "amesim_p4node2"
MODEL_VERSION = "0.3.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
@@ -1386,6 +1386,9 @@ class AmesimLmechn1(AlgebraicComponent):
MODEL_TYPE = "amesim_lmechn1"
MODEL_VERSION = "0.2.0"
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("force_balance",)
)
PORTS = tuple(
PortDefinition.mechanical_translational(f"port_{index}")
for index in range(1, 22)
@@ -17,6 +17,9 @@ class Orifice(AlgebraicComponent):
MODEL_TYPE = "orifice"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -116,4 +119,3 @@ class Orifice(AlgebraicComponent):
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_a.h_outflow = connected_h["port_b"]
self.port_b.h_outflow = connected_h["port_a"]
@@ -17,6 +17,9 @@ class ResistivePipe(AlgebraicComponent):
MODEL_TYPE = "pipe"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
@@ -15,6 +15,9 @@ class Tee(AlgebraicComponent):
MODEL_TYPE = "tee"
MODEL_VERSION = "1.0.0"
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
("mass_flow_balance",)
)
PORTS = (
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
+20 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from app.simulation.core.catalog import ComponentDisplaySpec
@@ -28,6 +28,13 @@ class Component(ABC):
# they override either stream hook, the closure planner retains the legacy
# full-network thermofluid fixed point.
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
# Exact residual suffixes whose declared variables are summed, in order,
# to form a ``sumToZero`` flow equation. The causal solver deliberately
# reads this capability from the concrete class ``__dict__``: subclasses
# must repeat the promise after changing any equation semantics.
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[
frozenset[str]
] = frozenset()
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
@@ -245,6 +252,18 @@ class Component(ABC):
for equation in self.pressure_flow_equation_residuals()
)
def pressure_flow_equation_value_readers(
self,
) -> Mapping[str, Callable[[], float]]:
"""Return explicitly separable scalar residual readers.
The solver consumes this optional capability only when the concrete
component class declares the method itself. Subclasses therefore
cannot accidentally inherit an equation-purity promise.
"""
return {}
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
"""Update connector outflow properties from current flow directions."""
+75 -7
View File
@@ -399,6 +399,19 @@ def _case_observation(
checkpoint_details.append(detail)
if not detail["available"]:
issues.append("checkpointUnavailable")
requested_time = checkpoint.get("requestedTime")
actual_time = checkpoint.get("actualTime")
if (
not isinstance(requested_time, (int, float))
or not isinstance(actual_time, (int, float))
or not math.isclose(
float(requested_time),
float(actual_time),
rel_tol=0.0,
abs_tol=tolerance.checkpoint_time_absolute_seconds,
)
):
issues.append("checkpointTimeMismatch")
if expected_projection_count is not None and len(state_values) != int(
expected_projection_count
):
@@ -455,6 +468,7 @@ def _pair_checkpoints(
right: Mapping[str, object],
*,
tolerance: MatrixTolerance,
strict_prefix_stop: float | None = None,
) -> dict[str, object]:
left_checkpoints = _checkpoints(_summary(left))
right_checkpoints = _checkpoints(_summary(right))
@@ -463,6 +477,11 @@ def _pair_checkpoints(
left_time = left_checkpoint.get("requestedTime")
if not isinstance(left_time, (int, float)):
continue
if strict_prefix_stop is not None and not (
float(left_time)
< strict_prefix_stop - tolerance.checkpoint_time_absolute_seconds
):
continue
match = next(
(
right_checkpoint
@@ -554,6 +573,7 @@ def _pair_checkpoints(
"commonCheckpointTimes": [
pair[0].get("requestedTime") for pair in paired
],
"strictPrefixStopTime": strict_prefix_stop,
"valueMismatchCount": mismatch_count,
"keySetMismatchCount": key_set_mismatch_count,
"nonnumericValueCount": nonnumeric_count,
@@ -569,6 +589,7 @@ def _compare_time_sequences(
*,
prefix_stop: float,
absolute_tolerance: float,
strict_prefix: bool,
) -> dict[str, object]:
left_times = _finite_event_times(left)
right_times = _finite_event_times(right)
@@ -579,12 +600,12 @@ def _compare_time_sequences(
"left": left,
"right": right,
}
left_prefix = [
value for value in left_times if value <= prefix_stop + absolute_tolerance
]
right_prefix = [
value for value in right_times if value <= prefix_stop + absolute_tolerance
]
if strict_prefix:
include = lambda value: value < prefix_stop - absolute_tolerance
else:
include = lambda value: value <= prefix_stop + absolute_tolerance
left_prefix = [value for value in left_times if include(value)]
right_prefix = [value for value in right_times if include(value)]
passed = len(left_prefix) == len(right_prefix) and all(
math.isclose(
left_value,
@@ -607,6 +628,7 @@ def _pair_events(
right: Mapping[str, object],
*,
prefix_stop: float,
strict_prefix: bool,
tolerance: MatrixTolerance,
) -> dict[str, object]:
left_trace = _event_trace(_summary(left))
@@ -616,12 +638,14 @@ def _pair_events(
right_trace.get("signalEventTimes"),
prefix_stop=prefix_stop,
absolute_tolerance=tolerance.signal_event_time_absolute_seconds,
strict_prefix=strict_prefix,
)
mechanical = _compare_time_sequences(
left_trace.get("mechanicalTransitionTimes"),
right_trace.get("mechanicalTransitionTimes"),
prefix_stop=prefix_stop,
absolute_tolerance=tolerance.event_time_absolute_seconds,
strict_prefix=strict_prefix,
)
availability = (
left_trace.get("mechanicalTransitionTimesAvailable") is not False
@@ -631,6 +655,7 @@ def _pair_events(
"evaluated": bool(left_trace) and bool(right_trace),
"passed": bool(signal["passed"] and mechanical["passed"] and availability),
"prefixStopTime": prefix_stop,
"strictPrefix": strict_prefix,
"signalEventTimes": signal,
"mechanicalTransitionTimes": mechanical,
"mechanicalTransitionTimesAvailable": availability,
@@ -701,17 +726,29 @@ def compare_matrix_cases(
left_stop = float(left["stopTime"])
right_stop = float(right["stopTime"])
prefix_stop = min(left_stop, right_stop)
strict_prefix = not math.isclose(
left_stop,
right_stop,
rel_tol=0.0,
abs_tol=tolerance.checkpoint_time_absolute_seconds,
)
if not _completed(left, left_stop) or not _completed(right, right_stop):
return {
"evaluated": False,
"passed": False,
"reason": "oneOrBothCasesDidNotComplete",
}
state = _pair_checkpoints(left, right, tolerance=tolerance)
state = _pair_checkpoints(
left,
right,
tolerance=tolerance,
strict_prefix_stop=prefix_stop if strict_prefix else None,
)
events = _pair_events(
left,
right,
prefix_stop=prefix_stop,
strict_prefix=strict_prefix,
tolerance=tolerance,
)
diagnostics = _pair_diagnostics(
@@ -728,6 +765,7 @@ def compare_matrix_cases(
"evaluated": True,
"passed": passed,
"commonPrefixStopTime": prefix_stop,
"strictPrefix": strict_prefix,
"stateProjection": state,
"events": events,
"diagnostics": diagnostics,
@@ -874,6 +912,36 @@ def run_max_step_matrix(
if sampling_mode == "source"
else _finite_positive(lane_config.get("sampleStep"), field="lane.sampleStep")
)
source_start = float(source_config["tStart"])
checkpoint_grid_tolerance = max(
1.0e-12,
8.0 * math.ulp(max(1.0, abs(source_start))),
8.0 * math.ulp(max(1.0, abs(sample_step))),
)
for horizon in selected_horizons:
for checkpoint_time in horizon.checkpoint_times:
if math.isclose(
checkpoint_time,
horizon.stop_time,
rel_tol=0.0,
abs_tol=checkpoint_grid_tolerance,
):
continue
grid_index = round((checkpoint_time - source_start) / sample_step)
grid_time = source_start + grid_index * sample_step
if not math.isclose(
checkpoint_time,
grid_time,
rel_tol=0.0,
abs_tol=checkpoint_grid_tolerance,
):
raise RegressionManifestError(
"Matrix checkpoint "
f"{checkpoint_time:.17g} is not represented by the "
f"{sample_step:.17g} s output grid for "
f"{horizon.case_id!r}; use the event trace for off-grid "
"transition times."
)
instrumentation_mode = str(
lane_config.get("instrumentationMode", "standard")
)
+208 -4
View File
@@ -35,6 +35,12 @@ CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_EXECUTOR_V2"
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_COORDINATE_KERNEL"
)
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS"
)
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS"
)
CAUSAL_FAST_PATH_AUDIT_INTERVAL = 64
@@ -57,6 +63,20 @@ def _causal_coordinate_kernel_environment_enabled() -> bool:
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_direct_sum_assignments_environment_enabled() -> bool:
"""Return whether exact sum-to-zero targets bypass component tuples."""
value = os.getenv(CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_direct_equation_readers_environment_enabled() -> bool:
"""Return whether exact-class scalar residual readers are enabled."""
value = os.getenv(CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
class AlgebraicSolveError(RuntimeError):
def __init__(
self,
@@ -127,6 +147,7 @@ class EffortAnchor:
unknown: AlgebraicUnknown
evaluate: Callable[[], float]
equation_id: str
causal_evaluate: Callable[[], float] | None = None
@dataclass(frozen=True)
@@ -326,6 +347,14 @@ class PressureFlowSolver:
self.residual_tolerance = residual_tolerance
self.max_evaluations = max_evaluations
self.scope_kind = scope_kind
self._causal_direct_sum_assignments_environment_enabled = (
_causal_direct_sum_assignments_environment_enabled()
)
self._causal_direct_sum_flow_assignment_count = 0
self._causal_direct_equation_readers_environment_enabled = (
_causal_direct_equation_readers_environment_enabled()
)
self._causal_direct_effort_anchor_count = 0
self.unknowns = self._build_unknowns()
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
self._unknowns_by_variable = {
@@ -371,6 +400,9 @@ class PressureFlowSolver:
for item in self._component_equation_plan
for equation in item.templates
) + tuple(item.template for item in self._connection_equation_plan)
self._causal_direct_equation_readers = (
self._build_causal_direct_equation_readers()
)
self._effort_groups = {
variable: self._build_effort_equality_groups(variable)
for variable in ("p", "x", "v")
@@ -418,6 +450,11 @@ class PressureFlowSolver:
self._causal_fast_path_eligible,
self._causal_fast_path_fallback_reason,
) = self._build_causal_execution_plan()
self._causal_direct_effort_anchor_count = sum(
assignment.anchor.causal_evaluate is not None
for assignments in self._causal_effort_plan_by_variable.values()
for assignment in assignments
)
self._causal_fast_path_environment_enabled = (
_causal_fast_path_environment_enabled()
)
@@ -599,6 +636,18 @@ class PressureFlowSolver:
"coordinateKernelFastSolveCount": (
self._causal_coordinate_fast_solve_count
),
"directSumAssignmentsConfigured": (
self._causal_direct_sum_assignments_environment_enabled
),
"directSumFlowAssignmentCount": (
self._causal_direct_sum_flow_assignment_count
),
"directEquationReadersConfigured": (
self._causal_direct_equation_readers_environment_enabled
),
"directEffortAnchorCount": (
self._causal_direct_effort_anchor_count
),
"compiledEffortUnknownCount": (
self._causal_compiled_effort_unknown_count
),
@@ -850,6 +899,9 @@ class PressureFlowSolver:
] = {}
component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {}
for coordinate_index, assignment in indexed_assignments:
if assignment.anchor.causal_evaluate is not None:
direct_targets.append((coordinate_index, assignment))
continue
equation_index = equation_index_by_id[
assignment.anchor.equation_id
]
@@ -934,10 +986,22 @@ class PressureFlowSolver:
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()
evaluate = (
assignment.anchor.causal_evaluate
if assignment.anchor.causal_evaluate is not None
else assignment.anchor.evaluate
)
try:
workspace[coordinate_index] = (
self._read_effort_anchor(assignment)
- evaluate()
)
except MemoryError:
raise
except Exception:
if assignment.anchor.causal_evaluate is None:
raise
return False
for evaluation in stage.component_evaluations:
equation_values = evaluation.evaluate()
for target in evaluation.targets:
@@ -975,7 +1039,19 @@ class PressureFlowSolver:
return False
for assignment in assignments:
anchor = assignment.anchor
target = anchor.unknown.read() - anchor.evaluate()
evaluate = (
anchor.causal_evaluate
if anchor.causal_evaluate is not None
else anchor.evaluate
)
try:
target = anchor.unknown.read() - evaluate()
except MemoryError:
raise
except Exception:
if anchor.causal_evaluate is None:
raise
return False
if not isfinite(target) or (
variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
):
@@ -1431,6 +1507,9 @@ class PressureFlowSolver:
unknown=unknown,
evaluate=self._equation_value_reader(equation),
equation_id=equation.id,
causal_evaluate=(
self._causal_direct_equation_readers.get(equation.id)
),
)
)
@@ -1851,6 +1930,45 @@ class PressureFlowSolver:
return read_component_equation
def _build_causal_direct_equation_readers(
self,
) -> dict[str, Callable[[], float]]:
"""Compile exact-class scalar residual capabilities, failing closed."""
if not self._causal_direct_equation_readers_environment_enabled:
return {}
readers: dict[str, Callable[[], float]] = {}
for evaluation in self._component_equation_plan:
component = evaluation.component
# A subclass must repeat the declaration after changing equation
# semantics; inherited purity promises are deliberately ignored.
declared = type(component).__dict__.get(
"pressure_flow_equation_value_readers"
)
if not callable(declared):
continue
try:
component_readers = declared(component)
except MemoryError:
raise
except Exception:
continue
if not isinstance(component_readers, Mapping):
continue
template_ids = frozenset(
template.id for template in evaluation.templates
)
if any(
not isinstance(equation_id, str)
or equation_id not in template_ids
or not callable(reader)
for equation_id, reader in component_readers.items()
):
continue
readers.update(component_readers)
return readers
def _connection_flow_target_reader(
self,
equation,
@@ -1870,6 +1988,69 @@ class PressureFlowSolver:
f"Connection flow equation {equation.id} does not contain {unknown.id}."
)
def _sum_to_zero_flow_target_reader(
self,
equation,
unknown: AlgebraicUnknown,
) -> Callable[[], float]:
"""Compile the exact target of a declared flow sum without callbacks."""
if equation.relation != "sumToZero":
raise ValueError(
f"Equation {equation.id} is not a sum-to-zero relation."
)
if len(set(equation.variables)) != len(equation.variables):
raise ValueError(
f"Equation {equation.id} repeats a sum-to-zero variable."
)
equation_unknowns: list[AlgebraicUnknown] = []
for variable in equation.variables:
current = self._unknowns_by_id.get(variable)
if current is None or current.role != "flow":
raise ValueError(
f"Equation {equation.id} has a non-flow variable."
)
equation_unknowns.append(current)
if not any(current is unknown for current in equation_unknowns):
raise ValueError(
f"Equation {equation.id} does not contain {unknown.id}."
)
compiled_unknowns = tuple(equation_unknowns)
if len(compiled_unknowns) == 2:
first, second = compiled_unknowns
return lambda: 0.0 - (first.read() + second.read())
def target() -> float:
# The target unknown is already zeroed by the stage executor. Keep
# its slot in the sum so the floating-point operation order matches
# the declared built-in residual, including near cancellation.
return 0.0 - sum(current.read() for current in compiled_unknowns)
return target
@staticmethod
def _component_declares_exact_sum_to_zero_equation(
component: object,
equation_id: str,
) -> bool:
"""Accept only an exact concrete-class promise for callback bypass."""
declared_suffixes = type(component).__dict__.get(
"PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES"
)
if not isinstance(declared_suffixes, frozenset) or any(
not isinstance(suffix, str) or not suffix or ":" in suffix
for suffix in declared_suffixes
):
return False
component_name = getattr(component, "name", None)
if not isinstance(component_name, str):
return False
return any(
equation_id == f"{component_name}:{suffix}"
for suffix in declared_suffixes
)
def _pressure_flow_equation_values(self) -> tuple[float, ...]:
"""Evaluate live equation values through the compiled topology."""
@@ -1918,6 +2099,29 @@ class PressureFlowSolver:
evaluation = self._component_equation_plans_by_id[equation.owner_id]
component = evaluation.component
if (
self._causal_direct_sum_assignments_environment_enabled
and equation.relation == "sumToZero"
and self._component_declares_exact_sum_to_zero_equation(
component,
equation.id,
)
):
try:
evaluate = self._sum_to_zero_flow_target_reader(
equation,
unknown,
)
except ValueError:
pass
else:
self._causal_direct_sum_flow_assignment_count += 1
return ExplicitFlowAssignment(
equation_id=equation.id,
unknown=unknown,
evaluate=evaluate,
)
equations = evaluation.templates
equation_ids = tuple(current.id for current in equations)
try:
+22 -11
View File
@@ -378,13 +378,7 @@ class CausalNumericIR:
workspace.effort_written[output] = True
except MemoryError:
raise
except (
ArithmeticError,
IndexError,
RuntimeError,
TypeError,
ValueError,
) as exc:
except Exception 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")
@@ -530,6 +524,7 @@ def _unique_slots(items: Iterable[int]) -> tuple[int, ...]:
def _compile_effort_evaluations(
operations: tuple[CausalIREffortOperation, ...],
anchor_evaluators: tuple[Callable[[], float], ...],
direct_residuals: tuple[bool, ...],
component_locations: dict[
str, tuple[object, Callable[[], tuple[float, ...]], int]
],
@@ -538,10 +533,14 @@ def _compile_effort_evaluations(
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)
for output, (operation, anchor_evaluate, direct_residual) in enumerate(
zip(operations, anchor_evaluators, direct_residuals)
):
location = component_locations.get(operation.equation_id)
location = (
None
if direct_residual
else component_locations.get(operation.equation_id)
)
if location is None:
direct.append((output, anchor_evaluate, operation.equation_id))
continue
@@ -638,6 +637,7 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
for variable in ("p", "x", "v"):
operations: list[CausalIREffortOperation] = []
anchors: list[Callable[[], float]] = []
direct_residuals: list[bool] = []
for assignment in effort_plan[variable]:
result = len(canonical_slots)
equation_id = str(assignment.anchor.equation_id)
@@ -665,7 +665,17 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
equation_id,
)
)
anchors.append(assignment.anchor.evaluate)
causal_evaluate = getattr(
assignment.anchor,
"causal_evaluate",
None,
)
anchors.append(
causal_evaluate
if causal_evaluate is not None
else assignment.anchor.evaluate
)
direct_residuals.append(causal_evaluate is not None)
operation_tuple = tuple(operations)
effort_stages.append(
CausalIREffortStage(
@@ -674,6 +684,7 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
_compile_effort_evaluations(
operation_tuple,
tuple(anchors),
tuple(direct_residuals),
component_locations,
evaluators,
),
+387 -15
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
import os
from typing import Callable, Literal, Mapping, Sequence
from app.simulation.components.amesim.mechanical.translational import (
AmesimLstp00a,
AmesimMecmas21,
)
from app.simulation.core.base import DynamicComponent
@@ -12,15 +15,114 @@ from app.simulation.systems.network import SimulationNetwork
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
MechanicalAbsoluteToleranceMode = Literal["legacy", "contact-aware-v1"]
DenseState = Callable[[float], Sequence[float]]
MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE = (
"SIMULATION_MECHANICAL_ATOL_MODE"
)
def _requested_mechanical_absolute_tolerance_mode(
) -> MechanicalAbsoluteToleranceMode:
value = os.environ.get(
MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE,
"legacy",
).strip().lower()
if value == "legacy":
return "legacy"
if value in {"contact-aware-v1", "contact_aware_v1", "contact-aware"}:
return "contact-aware-v1"
raise ValueError(
f"{MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE} must be "
"'legacy' or 'contact-aware-v1'."
)
@dataclass(frozen=True)
class MechanicalToleranceGroupPlan:
"""One rigid-coordinate group's state tolerances and proof result."""
components: tuple[str, ...]
contacts: tuple[str, ...]
eligible: bool
reason: str
velocity_atol: float
position_atol: float
minimum_dvel: float | None
minimum_contact_damping_length: float | None
minimum_damping_strength_ratio: float | None
minimum_force_limited_velocity_atol: float | None
def as_dict(self) -> dict[str, object]:
return {
"components": list(self.components),
"contacts": list(self.contacts),
"eligible": self.eligible,
"reason": self.reason,
"velocityAtol": self.velocity_atol,
"positionAtol": self.position_atol,
"minimumDvel": self.minimum_dvel,
"minimumContactDampingLength": (
self.minimum_contact_damping_length
),
"minimumDampingStrengthRatio": (
self.minimum_damping_strength_ratio
),
"minimumForceLimitedVelocityAtol": (
self.minimum_force_limited_velocity_atol
),
}
@dataclass(frozen=True)
class MechanicalAbsoluteTolerancePlan:
"""State-aligned absolute tolerances with auditable group proofs."""
mode: MechanicalAbsoluteToleranceMode
default_atol: float
legacy_mechanical_atol: float
values: tuple[float, ...]
groups: tuple[MechanicalToleranceGroupPlan, ...]
def as_dict(self) -> dict[str, object]:
legacy_value = min(
self.default_atol,
self.legacy_mechanical_atol,
)
relaxed_groups = tuple(
group
for group in self.groups
if group.velocity_atol > legacy_value
)
return {
"mode": self.mode,
"defaultAtol": self.default_atol,
"legacyMechanicalAtol": self.legacy_mechanical_atol,
"stateCount": len(self.values),
"groupCount": len(self.groups),
"eligibleGroupCount": sum(group.eligible for group in self.groups),
"relaxedVelocityGroupCount": len(relaxed_groups),
"relaxedVelocityStateCount": len(relaxed_groups),
"relaxedPositionStateCount": 0,
"minimumEffectiveAtol": (
min(self.values) if self.values else None
),
"maximumEffectiveAtol": (
max(self.values) if self.values else None
),
"groups": [group.as_dict() for group in self.groups],
}
@dataclass
class MechanicalConstraintGroup:
"""MECMAS21 inertias that share one rigid translational coordinate."""
components: tuple[AmesimMecmas21, ...]
mode: ConstraintMode = "uninitialized"
contact_components: tuple[AmesimLstp00a, ...] = ()
@property
def representative(self) -> AmesimMecmas21:
@@ -361,9 +463,52 @@ class MechanicalStateReducer:
roots = (find("x", first_port), find("v", first_port))
masses_by_roots.setdefault(roots, []).append(component)
contacts_by_roots: dict[
tuple[tuple[str, str], tuple[str, str]],
dict[str, AmesimLstp00a],
] = {}
for component in self.network.components.values():
if not isinstance(component, AmesimLstp00a):
continue
contact_roots = tuple(
(
find("x", (component.name, definition.name)),
find("v", (component.name, definition.name)),
)
for definition in component.active_port_definitions
if (
definition.kind == "physical"
and definition.domain == "mechanical"
)
)
if (
len({roots[0] for roots in contact_roots}) < 2
or len({roots[1] for roots in contact_roots}) < 2
):
# A compliant contact whose two ports resolve to the same
# rigid coordinate cannot damp that coordinate. Treating the
# self-loop as proof would relax an unrelated velocity state.
continue
for definition in component.active_port_definitions:
if (
definition.kind != "physical"
or definition.domain != "mechanical"
):
continue
endpoint = (component.name, definition.name)
roots = (find("x", endpoint), find("v", endpoint))
contacts_by_roots.setdefault(roots, {})[
component.name
] = component
return tuple(
MechanicalConstraintGroup(tuple(components))
for components in masses_by_roots.values()
MechanicalConstraintGroup(
components=tuple(components),
contact_components=tuple(
contacts_by_roots.get(roots, {}).values()
),
)
for roots, components in masses_by_roots.items()
)
def _build_state_entries(self) -> tuple[StateEntry, ...]:
@@ -391,26 +536,253 @@ class MechanicalStateReducer:
def has_state_events(self) -> bool:
return any(group.discrete_endstop_components for group in self.groups)
def absolute_tolerance_plan(
self,
default: float,
*,
mechanical: float = 1.0e-12,
mode: MechanicalAbsoluteToleranceMode | None = None,
) -> MechanicalAbsoluteTolerancePlan:
"""Compile state tolerances without weakening non-smooth coordinates.
A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued
endstop position across the much smaller unilateral boundary band while
constructing finite-difference Jacobians. Positions and ideal endstop
states therefore retain the legacy machine-scale tolerance.
Strongly damped, compliant LSTP contact can instead drive a *free*
velocity close to zero for hundreds of accepted steps. Only a
compile-proven smooth-contact group may use the bounded velocity floor;
the contact position coordinate remains unchanged.
"""
default_atol = float(default)
mechanical_atol = float(mechanical)
if not isfinite(default_atol) or default_atol <= 0.0:
raise ValueError(
"default absolute tolerance must be finite and positive."
)
if not isfinite(mechanical_atol) or mechanical_atol <= 0.0:
raise ValueError(
"mechanical absolute tolerance must be finite and positive."
)
selected_mode = mode or _requested_mechanical_absolute_tolerance_mode()
if selected_mode not in {"legacy", "contact-aware-v1"}:
raise ValueError(
"mechanical absolute tolerance mode must be 'legacy' or "
"'contact-aware-v1'."
)
legacy_atol = min(default_atol, mechanical_atol)
values: list[float] = []
group_plans: list[MechanicalToleranceGroupPlan] = []
for entry in self.state_entries:
if not isinstance(entry, MechanicalConstraintGroup):
values.extend([default_atol] * entry.state_size)
continue
components = entry.components
contacts = entry.contact_components
positive_dvel = tuple(
float(component.dvel)
for component in components
if isfinite(float(component.dvel))
and float(component.dvel) > 0.0
)
positive_pdis = tuple(
float(contact.Pdis)
for contact in contacts
if isfinite(float(contact.Pdis))
and float(contact.Pdis) > 0.0
)
minimum_dvel = min(positive_dvel, default=None)
minimum_pdis = min(positive_pdis, default=None)
contact_scale_valid = True
contact_force_velocity_limits_list: list[float] = []
damping_strength_ratios_list: list[float] = []
if selected_mode == "contact-aware-v1" and minimum_dvel is not None:
for contact in contacts:
stiffness = float(contact.kcont)
damping_length = float(contact.Pdis)
damping = float(contact.rcont)
if not (
isfinite(stiffness)
and stiffness > 0.0
and isfinite(damping_length)
and damping_length > 0.0
and isfinite(damping)
and damping > 0.0
):
contact_scale_valid = False
continue
elastic_force_scale = stiffness * damping_length
damping_force_scale = damping * minimum_dvel
if not (
isfinite(elastic_force_scale)
and elastic_force_scale > 0.0
and isfinite(damping_force_scale)
and damping_force_scale > 0.0
):
contact_scale_valid = False
continue
force_velocity_limit = (
1.0e-3 * elastic_force_scale / damping
)
damping_strength_ratio = (
damping_force_scale / elastic_force_scale
)
if not (
isfinite(force_velocity_limit)
and force_velocity_limit > 0.0
and isfinite(damping_strength_ratio)
and damping_strength_ratio > 0.0
):
contact_scale_valid = False
continue
contact_force_velocity_limits_list.append(
force_velocity_limit
)
damping_strength_ratios_list.append(
damping_strength_ratio
)
contact_force_velocity_limits = tuple(
contact_force_velocity_limits_list
)
minimum_force_velocity_atol = min(
contact_force_velocity_limits,
default=None,
)
damping_strength_ratios = tuple(
damping_strength_ratios_list
)
minimum_damping_strength_ratio = min(
damping_strength_ratios,
default=None,
)
if selected_mode == "legacy":
eligible = False
reason = "legacyMode"
elif entry.discrete_endstop_components:
eligible = False
reason = "discreteEndstop"
elif any(int(component.stoptype) != 4 for component in components):
eligible = False
reason = "unsupportedStopType"
elif any(
component.use_friction and float(component.fcoul) != 0.0
for component in components
):
eligible = False
reason = "dryFriction"
elif not contacts:
eligible = False
reason = "noFlexibleContact"
elif any(
not isfinite(float(contact.Pdis))
or float(contact.Pdis) <= 0.0
for contact in contacts
):
eligible = False
reason = "nonSmoothContactDampingLength"
elif any(
not isfinite(float(contact.rcont))
or float(contact.rcont) <= 0.0
for contact in contacts
):
eligible = False
reason = "undampedContact"
elif any(
not isfinite(float(contact.kcont))
or float(contact.kcont) <= 0.0
for contact in contacts
):
eligible = False
reason = "invalidContactStiffness"
elif not contact_scale_valid:
eligible = False
reason = "invalidContactScale"
elif any(
int(contact.discContactOption) != 1
for contact in contacts
):
eligible = False
reason = "clampedContactForce"
elif len(positive_dvel) != len(components):
eligible = False
reason = "invalidVelocityScale"
elif (
len(damping_strength_ratios) != len(contacts)
or minimum_damping_strength_ratio is None
or minimum_damping_strength_ratio < 1.0
):
eligible = False
reason = "weakContactDamping"
else:
eligible = True
reason = "eligibleFlexibleContact"
velocity_atol = legacy_atol
if eligible:
assert minimum_dvel is not None
assert minimum_force_velocity_atol is not None
velocity_atol = min(
default_atol,
max(
mechanical_atol,
min(
1.0e-9,
1.0e-3 * minimum_dvel,
minimum_force_velocity_atol,
),
),
)
position_atol = legacy_atol
values.extend((velocity_atol, position_atol))
group_plans.append(
MechanicalToleranceGroupPlan(
components=tuple(
component.name for component in components
),
contacts=tuple(contact.name for contact in contacts),
eligible=eligible,
reason=reason,
velocity_atol=velocity_atol,
position_atol=position_atol,
minimum_dvel=minimum_dvel,
minimum_contact_damping_length=minimum_pdis,
minimum_damping_strength_ratio=(
minimum_damping_strength_ratio
),
minimum_force_limited_velocity_atol=(
minimum_force_velocity_atol
),
)
)
return MechanicalAbsoluteTolerancePlan(
mode=selected_mode,
default_atol=default_atol,
legacy_mechanical_atol=mechanical_atol,
values=tuple(values),
groups=tuple(group_plans),
)
def absolute_tolerances(
self,
default: float,
*,
mechanical: float = 1.0e-12,
mode: MechanicalAbsoluteToleranceMode | None = None,
) -> list[float]:
"""Return state-aligned tolerances with machine-scale mechanics.
"""Return state-aligned values from the auditable tolerance plan."""
A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued
endstop position across the much smaller unilateral boundary band while
constructing finite-difference Jacobians. Mechanical coordinates need
a tighter floor; thermodynamic states retain the caller's tolerance.
"""
values: list[float] = []
for entry in self.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
values.extend([min(default, mechanical)] * 2)
else:
values.extend([default] * entry.state_size)
return values
return list(
self.absolute_tolerance_plan(
default,
mechanical=mechanical,
mode=mode,
).values
)
def reset_constraint_modes(self) -> None:
for group in self.groups:
+208 -13
View File
@@ -15,6 +15,135 @@ DenseState = Callable[[float], list[float]]
JacobianCallable = Callable[[float, object], object]
@dataclass(frozen=True)
class SolverActivitySnapshot:
"""Low-cost, additive view of work inside an integration task.
``accepted_time`` deliberately changes only after an accepted solver step.
Trial evaluations may continue to advance ``activity_sequence`` and
``current_trial_time`` while that public progress value stays fixed.
"""
activity_sequence: int
activity_kind: str
current_trial_time: float | None
rhs_call_count: int
accepted_step_sequence: int
accepted_time: float | None
solver_step_sequence: int
jacobian_evaluation_count: int
thermofluid_closure_count: int
def as_dict(self) -> dict[str, object]:
return {
"activitySequence": self.activity_sequence,
"activityKind": self.activity_kind,
"currentTrialTime": self.current_trial_time,
"rhsCallCount": self.rhs_call_count,
"acceptedStepSequence": self.accepted_step_sequence,
"acceptedTime": self.accepted_time,
"solverStepSequence": self.solver_step_sequence,
"jacobianEvaluationCount": self.jacobian_evaluation_count,
"thermofluidClosureCount": self.thermofluid_closure_count,
}
class SolverActivityTracker:
"""Single-writer activity telemetry for a solver worker.
The solver thread is the only writer and the stream thread only snapshots
scalar attributes. The sequence is published last, so a reader never
treats partially published fields as a newer completed activity update.
Passing no tracker to :func:`integrate_ode` is the zero-cost opt-out path.
"""
__slots__ = (
"_accepted_step_sequence",
"_accepted_time",
"_activity_kind",
"_activity_sequence",
"_current_trial_time",
"_jacobian_evaluation_count",
"_rhs_call_count",
"_solver_step_sequence",
"_thermofluid_closure_count",
)
def __init__(self) -> None:
self._activity_sequence = 0
self._activity_kind = "idle"
self._current_trial_time: float | None = None
self._rhs_call_count = 0
self._accepted_step_sequence = 0
self._accepted_time: float | None = None
self._solver_step_sequence = 0
self._jacobian_evaluation_count = 0
self._thermofluid_closure_count = 0
def _publish(self, kind: str, time: float | None = None) -> None:
self._activity_kind = kind
if time is not None:
self._current_trial_time = float(time)
self._activity_sequence += 1
def start_integration(self, time: float) -> None:
self._accepted_time = float(time)
self._publish("solver_initialization", time)
def record_phase(self, kind: str, time: float | None = None) -> None:
self._publish(kind, time)
def record_solver_step(self, time: float) -> None:
self._solver_step_sequence += 1
self._publish("solver_step", time)
def record_rhs(self, time: float) -> None:
self._rhs_call_count += 1
self._publish("rhs", time)
def record_jacobian(self, time: float) -> None:
self._jacobian_evaluation_count += 1
self._publish("jacobian", time)
def record_thermofluid_closure(self, time: float) -> None:
self._thermofluid_closure_count += 1
self._publish("thermofluid_closure", time)
def record_accepted_step(self, time: float) -> None:
accepted_time = float(time)
if (
self._accepted_time is not None
and accepted_time <= self._accepted_time
):
return
self._accepted_step_sequence += 1
self._accepted_time = accepted_time
self._publish("accepted_step", accepted_time)
def snapshot(self) -> SolverActivitySnapshot:
# ``activity_sequence`` is read last because writers publish it last.
activity_kind = self._activity_kind
current_trial_time = self._current_trial_time
rhs_call_count = self._rhs_call_count
accepted_step_sequence = self._accepted_step_sequence
accepted_time = self._accepted_time
solver_step_sequence = self._solver_step_sequence
jacobian_evaluation_count = self._jacobian_evaluation_count
thermofluid_closure_count = self._thermofluid_closure_count
activity_sequence = self._activity_sequence
return SolverActivitySnapshot(
activity_sequence=activity_sequence,
activity_kind=activity_kind,
current_trial_time=current_trial_time,
rhs_call_count=rhs_call_count,
accepted_step_sequence=accepted_step_sequence,
accepted_time=accepted_time,
solver_step_sequence=solver_step_sequence,
jacobian_evaluation_count=jacobian_evaluation_count,
thermofluid_closure_count=thermofluid_closure_count,
)
@dataclass(frozen=True)
class StateTransition:
"""A state reset located inside an accepted integration step."""
@@ -773,6 +902,7 @@ def _integrate_scipy_stepwise(
state_transition_handler: StateTransitionHandler | None = None,
jac_sparsity=None,
jac: JacobianCallable | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> ODESolution:
"""Initial stepwise integration path for breakpoints and state resets.
@@ -796,6 +926,18 @@ def _integrate_scipy_stepwise(
if solver_type is None:
raise ValueError(f"Unsupported integration method: {config.method}")
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
solver_jac = implicit_jac
if implicit_jac is not None and activity_tracker is not None:
original_jacobian = implicit_jac
def activity_jacobian(time, state):
activity_tracker.record_jacobian(float(time))
try:
return original_jacobian(time, state)
finally:
activity_tracker.record_phase("solver_step", float(time))
solver_jac = activity_jacobian
times = [float(config.t_start)]
states = [[float(value)] for value in initial_state]
@@ -886,8 +1028,8 @@ def _integrate_scipy_stepwise(
"max_step": segment_max_step,
}
if config.method in {"BDF", "Radau"}:
if implicit_jac is not None:
solver_options["jac"] = implicit_jac
if solver_jac is not None:
solver_options["jac"] = solver_jac
elif jac_sparsity is not None:
solver_options["jac_sparsity"] = jac_sparsity
requested_first_step = (
@@ -909,6 +1051,11 @@ def _integrate_scipy_stepwise(
start_segment = getattr(implicit_jac, "start_segment", None)
if start_segment is not None:
start_segment()
if activity_tracker is not None:
activity_tracker.record_phase(
"solver_initialization",
last_accepted_time,
)
solver = solver_type(
cancellable_rhs,
last_accepted_time,
@@ -980,6 +1127,10 @@ def _integrate_scipy_stepwise(
integration_end - last_accepted_time
),
)
if activity_tracker is not None:
activity_tracker.record_solver_step(
last_accepted_time
)
step_message = solver.step()
except IntegrationCancelled:
status = "cancelled"
@@ -1365,6 +1516,7 @@ def integrate_ode(
jac_sparsity=None,
jac: JacobianCallable | None = None,
recoverable_trial_retries: bool = False,
activity_tracker: SolverActivityTracker | None = None,
):
"""Integrate an ODE, optionally restarting at equation discontinuities.
@@ -1381,8 +1533,34 @@ def integrate_ode(
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.
``activity_tracker`` is optional and additive. When omitted, the numerical
call path and callback behavior are unchanged.
"""
integration_rhs = rhs
integration_accepted_step_callback = accepted_step_callback
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
original_rhs = rhs
def activity_rhs(time, state):
numeric_time = float(time)
activity_tracker.record_rhs(numeric_time)
try:
return original_rhs(time, state)
finally:
activity_tracker.record_phase("solver_step", numeric_time)
integration_rhs = activity_rhs
def activity_accepted_step(time: float) -> None:
activity_tracker.record_accepted_step(float(time))
if accepted_step_callback is not None:
accepted_step_callback(float(time))
integration_accepted_step_callback = activity_accepted_step
if (
state_transition_handler is not None
and config.t_stop < config.t_start
@@ -1406,22 +1584,22 @@ def integrate_ode(
except ImportError:
if normalized_breakpoints:
return _runge_kutta_4_segmented(
rhs,
integration_rhs,
initial_state,
config,
t_eval,
normalized_breakpoints,
cancel_check,
accepted_step_callback,
integration_accepted_step_callback,
state_transition_handler,
)
return _runge_kutta_4(
rhs,
integration_rhs,
initial_state,
config,
t_eval,
cancel_check,
accepted_step_callback,
integration_accepted_step_callback,
state_transition_handler,
)
@@ -1432,25 +1610,26 @@ def integrate_ode(
or recoverable_trial_retries
):
return _integrate_scipy_stepwise(
rhs,
integration_rhs,
initial_state,
config,
t_eval,
cancel_check or (lambda: False),
accepted_step_callback,
integration_accepted_step_callback,
normalized_breakpoints,
state_transition_handler,
jac_sparsity,
jac,
activity_tracker,
)
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
solve_rhs = rhs
solve_rhs = integration_rhs
if implicit_jac is not None:
observer = getattr(implicit_jac, "observe", None)
if observer is not None:
def observed_rhs(time, state):
derivative = rhs(time, state)
derivative = integration_rhs(time, state)
observer(float(time), state, derivative)
return derivative
@@ -1459,6 +1638,19 @@ def integrate_ode(
if start_segment is not None:
start_segment()
solve_jac = implicit_jac
if implicit_jac is not None and activity_tracker is not None:
original_jacobian = implicit_jac
def activity_jacobian(time, state):
activity_tracker.record_jacobian(float(time))
try:
return original_jacobian(time, state)
finally:
activity_tracker.record_phase("solver_step", float(time))
solve_jac = activity_jacobian
solve_options = {
"fun": solve_rhs,
"t_span": (config.t_start, config.t_stop),
@@ -1471,8 +1663,11 @@ def integrate_ode(
}
if config.first_step is not None:
solve_options["first_step"] = config.first_step
if implicit_jac is not None:
solve_options["jac"] = implicit_jac
if solve_jac is not None:
solve_options["jac"] = solve_jac
elif jac_sparsity is not None and config.method in {"BDF", "Radau"}:
solve_options["jac_sparsity"] = jac_sparsity
return solve_ivp(**solve_options)
direct_solution = solve_ivp(**solve_options)
if activity_tracker is not None and len(direct_solution.t):
activity_tracker.record_accepted_step(float(direct_solution.t[-1]))
return direct_solution
+78 -6
View File
@@ -31,6 +31,7 @@ from app.simulation.solvers.solver import (
IntegrationCancelled,
ODESolution,
SolveIVPConfig,
SolverActivityTracker,
integrate_ode,
)
from app.simulation.solvers.signal import SignalResolver
@@ -453,6 +454,7 @@ class GenericFluidSystem:
self.pneumatic_volume_propagation_count = 0
self._jacobian_sparsity = None
self._ode_tangent_provider: ThreePistonTangentProvider | None = None
self._activity_tracker: SolverActivityTracker | None = None
def _request_causal_residual_audit(self) -> None:
"""Make topology or mode boundaries verify the next causal closure."""
@@ -957,6 +959,8 @@ class GenericFluidSystem:
*,
record_rhs_outcome: bool = False,
) -> dict[str, dict[str, float]]:
if self._activity_tracker is not None:
self._activity_tracker.record_thermofluid_closure(time)
transaction = self._thermofluid_transaction_plan.capture()
last_algebraic_diagnostics = self._last_algebraic_diagnostics
last_algebraic_scope = self._last_algebraic_scope
@@ -1156,6 +1160,8 @@ class GenericFluidSystem:
self,
connected_h: dict[str, dict[str, float]],
) -> list[float]:
if self._activity_tracker is not None:
self._activity_tracker.record_phase("state_derivatives")
return self.pneumatic_storage_reducer.coupled_derivatives(
self.mechanical_state_reducer.state_derivatives(connected_h)
)
@@ -1194,7 +1200,32 @@ class GenericFluidSystem:
sample_step: float,
progress_callback: SimulationProgressCallback | None = None,
cancel_check: SimulationCancellationCheck | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> GenericSimulationResult:
previous_activity_tracker = self._activity_tracker
self._activity_tracker = activity_tracker
try:
return self._simulate(
config,
sample_step=sample_step,
progress_callback=progress_callback,
cancel_check=cancel_check,
activity_tracker=activity_tracker,
)
finally:
self._activity_tracker = previous_activity_tracker
def _simulate(
self,
config: SolveIVPConfig,
*,
sample_step: float,
progress_callback: SimulationProgressCallback | None = None,
cancel_check: SimulationCancellationCheck | None = None,
activity_tracker: SolverActivityTracker | None = None,
) -> GenericSimulationResult:
if activity_tracker is not None:
activity_tracker.record_phase("initializing", config.t_start)
last_reported_progress = -1.0
last_reported_phase = ""
@@ -1223,12 +1254,21 @@ class GenericFluidSystem:
report_progress(0.0, "initializing", force=True)
with performance_span("simulation.sample_initialization"):
integration_config = config
mechanical_tolerance_plan = None
if isinstance(config.atol, (int, float)):
mechanical_tolerance_plan = (
self.mechanical_state_reducer.absolute_tolerance_plan(
float(config.atol),
mode=(
None
if config.method == "BDF"
else "legacy"
),
)
)
integration_config = replace(
config,
atol=self.mechanical_state_reducer.absolute_tolerances(
float(config.atol)
),
atol=list(mechanical_tolerance_plan.values),
)
t_eval = simulation_sample_times(config, sample_step)
signal_event_times = self.signal_resolver.event_times(
@@ -1321,6 +1361,8 @@ class GenericFluidSystem:
def evaluate_jacobian_rhs(time, state):
if cancel_check is not None and cancel_check():
raise IntegrationCancelled
if activity_tracker is not None:
activity_tracker.record_rhs(float(time))
return monitored_rhs(
time,
[float(value) for value in state],
@@ -1372,6 +1414,7 @@ class GenericFluidSystem:
jac_sparsity=jac_sparsity,
jac=jacobian,
recoverable_trial_retries=True,
activity_tracker=activity_tracker,
)
finally:
self._ode_tangent_provider = None
@@ -1388,6 +1431,11 @@ class GenericFluidSystem:
else max(0.0, last_reported_progress)
)
report_progress(postprocess_progress, "postprocessing", force=True)
if activity_tracker is not None:
activity_tracker.record_phase(
"postprocessing",
furthest_solver_time,
)
times = [float(value) for value in solution.t]
if isinstance(solution, ODESolution):
solver_segment_diagnostics = [
@@ -1601,6 +1649,11 @@ class GenericFluidSystem:
self._close_current_state(times[time_index])
self._append_current_state(series)
series["time"].append(times[time_index])
if activity_tracker is not None:
activity_tracker.record_phase(
"postprocessing",
times[time_index],
)
except Exception as exc:
run_status = "failed"
result_message = str(exc)
@@ -1621,6 +1674,14 @@ class GenericFluidSystem:
diagnostics = {
"integration": {
"method": integration_config.method,
"mechanicalAbsoluteTolerance": (
mechanical_tolerance_plan.as_dict()
if mechanical_tolerance_plan is not None
else {
"mode": "callerVector",
"stateCount": len(initial_state),
}
),
"jacobianSparsity": jacobian_diagnostics,
"jacobian": runtime_jacobian_diagnostics,
"segmentCount": len(solver_segment_diagnostics),
@@ -1730,6 +1791,19 @@ class GenericFluidSystem:
for variable in self.network.result_variable_metadata()
if variable.key in series
)
final_simulated_time = (
float(series["time"][-1])
if series["time"]
else float(config.t_start)
)
if activity_tracker is not None:
activity_tracker.record_phase(
"complete" if run_status == "completed" else run_status,
final_simulated_time,
)
diagnostics["activity"] = (
activity_tracker.snapshot().as_dict()
)
report_progress(
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
"complete" if run_status == "completed" else run_status,
@@ -1740,9 +1814,7 @@ class GenericFluidSystem:
status=run_status,
message=result_message,
simulated_until=(
float(series["time"][-1])
if series["time"]
else float(config.t_start)
final_simulated_time
),
requested_stop_time=float(config.t_stop),
variables=variables,
+102 -43
View File
@@ -37,7 +37,9 @@
- `2.100000 s`:完成积分并进入后处理
- 总运行完成,无重试、无非线性回退,也没有无进度死锁
因此,当前证据支持“此前的 2.05 s 卡死在现版本中没有复现”;该区间仍存在数秒级慢推进。`10 s` 尚未验证,不能由本次结果外推保证。
因此,该历史输入的证据支持“此前的 2.05 s 卡死在当时版本中没有复现”;该区间仍存在数秒级慢推进。本节形成时尚未验证
`10 s`,不能由该次结果外推保证。后续主目标曾以历史 `maxStep=0.02 s` 完成单次 `10 s`,但当前权威
`maxStep=0.001 s` 的 `10 s` 基线仍未运行,两者不得混用。
### 2.2 环境说明
@@ -135,7 +137,8 @@
- [ ] `0.68–0.71 s`:历史慢区。
- [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。
- [ ] `2.00–2.10 s`:此前报告卡死区间和状态切换。
- [x] `10 s`:最终通用接线后的当前工作树已完成首次长时间模式变化运行;连续 3 次和批准 golden 仍属于 OPT-09 后续。
- [x] `10 s / maxStep=0.02 s`:2026-08-17 最终通用接线版本完成一次历史长时间模式变化运行。
- [ ] `10 s / maxStep=0.001 s`:当前权威配置尚未运行;连续 3 次、批准 golden 与完整步长矩阵仍属于 OPT-09。
每个检查点比较:连续状态、关键压力/流量/位移/速度、事件时刻与顺序、模式状态、有限性、最大缩放残差及守恒量。
@@ -162,39 +165,65 @@
- **步长细化可解性不变量**:在约定工程区间内,如果较大的 `maxStep` 能完成同一时域,则更小
`maxStep` 不得反而出现不可恢复数值失败。更小上限可以更慢;若仅因工作量增加超过预算,必须归类为
`budget_limited` 并证明仍持续推进,不能记为 solver failure 或借此选择一个“幸运步长”。
- [ ] 首先对账 JSON、浏览器生成的 System XML、服务请求和 worker 最终生效参数;保存输入哈希,禁止用 runner 的内存覆盖掩盖浏览器路径问题。
- [ ] 浏览器当前工程验收明确为失败:`0.2 s / 0.001 s` 在 `t≈0.0489 s` 被判定计算超时;离线 production runner 的完成记录只作对照,不能写成浏览器正常完成。必须用同一 JSON 复现并分类为内部慢步、求解器真停滞、后处理/传输或服务假超时。
- [ ] 第一阶段在其余参数不变时串行运行 `tStop={0.2,1} s × maxStep={0.001,0.002,0.005,0.01,0.02} s` 的短时二维矩阵;最终签收要求所有单元到达终点,不得出现 NaN/Inf、不可恢复数值错误或无解释回退。定位阶段允许记录有持续进展证据的 `budget_limited`,但它既不算数值失败,也不算通过。
- [ ] 短时二维矩阵通过后,再按相同契约依次延长到 `2 s → 5 s → 10 s`;每一档先运行权威 `0.001 s`,再运行代表性的 `0.005/0.02 s`,最后补齐其余步长。每一档先确认同 `maxStep` 公共前缀,再进入下一档,不因较长 `tStop` 改变已覆盖区间的成败结论。
- [ ] 跨 `maxStep` 不要求逐位相同;按积分状态、派生量、关键压力/流量、守恒量、事件和离散模式分别验收,近零派生加速度不得使用统一相对误差误判真实积分状态。
- [x] 已对账 JSON、浏览器生成 System XML 的参数直传代码、服务请求和 worker 最终回显:`BDF / 0.2 s / 0.01 s / 0.001 s` 没有被前端或后端改写;JSON/XML 权威哈希及配对契约测试通过。
- [x] 已复现并分类浏览器 `t≈0.0489 s` 计算超时:同参流式 API 能完整到达 `0.2 s`,但修复前浏览器曾把一次仍有 CPU 活动的约 70 s 慢步误判为 `SOLVER_STALLED` 并主动取消。该结果是“服务假超时 + 后端真实慢区”,不是该状态的不可恢复数值失败或网络断流。
- [x] 已串行完成 `tStop={0.2,1} s × maxStep={0.001,0.002,0.005,0.01,0.02} s` 的 10 个短时单元:全部到达终点,`caseFailureCount=0`,无 NaN/Inf、不可恢复数值错误、热流体恢复或超时;同 `maxStep` 的 `0.2→1 s` 严格公共前缀五档均通过。
- [x] 已将同一五档矩阵延长到 `2 s`:5 个单元全部到达终点、0 次恢复重试,`1→2 s` 的严格公共前缀五档逐位一致,机械事件顺序一致且时刻跨度不超过 `9.58 µs`。
- [ ] 继续延长到 `5 s → 10 s`。2026-08-19 的 5 s 尝试在用户要求下中止且未生成聚合报告;当前代码的权威 `10 s / 0.001 s` 未运行。
- [x] 已人工分层复核跨 `maxStep` 结果:`0.2/1 s` 的积分 `v/x` 无超差;`2 s` 的压力、守恒、离散模式和事件通过,差异集中于接触后的近零 `v/a` 及 `1.85–1.90 s` 流量换向附近。
- [ ] 将上述分层判据自动接入矩阵 runner。当前报告顶层仍因旧的统一 state comparator 把派生 `a`(以及 2 s 接触后的近零 `v`)计为 comparison failure,不能写成“矩阵整体 passed”。
- [ ] 每个 `0.01 s` 模拟区间记录墙钟、实际 `h_abs`/BDF 阶次、接受/拒绝步、重启、`nfev/njev/nlu`、Jacobian 构建、stream/热流体闭合与恢复轨迹;性能悬崖必须能定位到具体阶段和组件。
- [ ] 内部 RHS、Jacobian、stream/热流体闭合或恢复循环仍有活动时,服务持续发送活动心跳,浏览器不得因 60 s 内没有接受步而误判卡死;真正无活动必须有界终止并报告最后阶段、时刻、步长和计数。
- [x] 内部 RHS、solver step、stream/热流体闭合仍有活动时,服务持续发送活动心跳;真实浏览器已证明接受进度平台期不会再因 60 s 规则被误杀。
- [ ] 真正无活动时仍须实现可硬终止的隔离 worker,并报告最后阶段、时刻、步长和计数;当前线程内合作取消不能杀死永不返回的本地调用。
- [ ] 将终止结果明确分类为 `numerical_failure`、`service_timeout_worker_active`、`active_slow_trial`、`true_stall` 或 `budget_limited`;浏览器超时始终属于工程路径未通过,但在证据不足时不得冒充数值失败。
- [ ] 任一会改变数值路径、事件语义、容差或默认求解策略的修复,必须先形成“复现证据 → 首个异常阶段 → 根因假设 → 最小方案 → A/B 判据 → 回退方式”,提交审阅后再实施;每轮只修改一个概念并先复跑原失败单元。
- [ ] 在 `0.2/1 s` 短时门通过后,依次取得 `2/5/10 s` 数据;在对本次长跑做任何性能调优前,先完整取得一次 `10 s / 0.001 s` 原始基线,完成后才按时间线选择最小优化并复跑完整阶梯。
- [x] 用户后续明确批准先定位并解决 70 s 慢区;已只接受保持数值语义的精确热路径优化,并拒绝改变接触轨迹或表现更差的容差/Jacobian 候选。
- [ ] 恢复长时测试时,从完整的 5 s 报告继续,再取得当前优化版本的 `10 s / 0.001 s` 权威基线;历史 `0.02 s` 报告不得替代。
该门的目标不是寻找一个“碰巧能跑”的固定 `maxStep`,也不是要求所有步长得到位级相同轨迹,而是让合理工程区间内的
`maxStep` 只影响可解释的误差与成本,不决定仿真能否完成。
#### 2026-08-18 / 浏览器 `0.0489 s` 超时的修复前定位(步骤 1–3)
- 输入对账:权威 JSON/XML SHA-256 分别为 `b44bf540...` / `0a2d9331...`;诊断请求只把 XML 的 `tStop` 从 `10` 改为 `0.2`,请求载荷 SHA-256 为 `2e9d6577...`。前端 `resolveSimulationConfig` 与 `buildSystemXml` 对四个数值及方法直接序列化,后端最终回显 `tStop=0.2`、`sampleStep=0.01`、`maxStep=0.001`、`method=BDF`。
- API 实测:真实 `/api/system-xml/simulate-stream` 于 `164.954 s` 完成,`status=completed`、`success=true`、`simulatedUntil=0.2`、21 个采样点、2054 个接受步、0 次可恢复失败;`nfev/njev/nlu=6190/267/982`,与同配置离线 worker 轨迹一致。
- 决定性时间线:最后一次普通进度为 `t=0.048668428726 s`(15:29:03.676),下一次为 `t=0.049248338602 s`(15:30:14.045),间隔 `70.369 s`。期间后端每约 5 s 持续发送 heartbeat,求解线程采样约 `99%` 单核 CPU;第 60.416 s 的 heartbeat 到达时,前端按现有规则必然先抛出 `SOLVER_STALLED`。
- 直接根因:前端只以“非 heartbeat 的累计 accepted progress”刷新 60 s 计时;Generic 又把普通进度节流为总时域的 `0.25%`(本例为 `0.0005 s`)。因此内部 RHS/Jacobian/闭合仍在运行、甚至接受微步时,也可能被错误取消。30 s 网络 idle 门没有触发,因为 heartbeat 始终存在。
- 时域放大效应:该 `0.25%` 门槛随 `tStop` 变为 `0.2/1/2/5/10 s → 0.0005/0.0025/0.005/0.0125/0.025 s`。相同物理公共前缀在更长任务中会更少发送普通进度,更容易被 60 s 规则误杀;这会直接破坏时域延长不变量,不能通过单纯提高超时常数根治。
- 当时尚未归因的性能问题:后端确有约 70 s 满核慢区;修复前协议没有 RHS 调用数、trial time、Jacobian/闭合阶段和任务级活动序列,不能直接断言具体数值根因。
- 当时建议 A/B:将 accepted-time 平台期改为“活跃慢步”提示,并增加节流的任务级 activity telemetry;该方案已于后续实现并通过真实浏览器复验。
- 当时建议 C:记录 step/RHS/Jacobian/闭合增量以定位慢区;当前已完成离线 step/RHS/闭合及组件归因,但仍缺 BDF order、全程 `h_abs` 和 SciPy 内部有限差分 Jacobian 的实时精确分类。
#### 2026-08-19 / 慢区归因、精确优化与浏览器复验
- 数值根因:原 `0.048668428726→0.049248338602 s` 区间包含约 1385 个接受步,其中 1223 步小于 `1e-8 s`、186 步小于 `1e-9 s`,步长中位数 `1.409e-9 s`、最小值 `6.125e-11 s`;8 个微步簇与 8 个高刚度 `LSTP00A` 接触依次激活一一对应。因此它是刚性接触层中的真实慢推进,不是单次调用死锁。
- 工作量归因:该区间 `nfev/njev/nlu=3527/90/453`,约 4786 次额外 RHS 来自 SciPy 稀疏有限差分 Jacobian;RHS 墙钟绝大部分位于 `_close_current_state`。全部压力/流量代数解走已播种因果路径,无非线性、块或稠密回退。
- 服务修复:后端增加 `activitySequence/activityKind/currentTrialTime/rhsCallCount/acceptedStepSequence/acceptedTime` 及 solver/Jacobian/闭合计数快照;5 s heartbeat 携带快照。前端仅在 `integrating` 阶段的 accepted 与 activity 同时连续 60 s 不变时判停,activity 继续推进时只提示慢步,缺少新字段时也不误杀;30 s 完全无字节的传输门保持不变。
- 精确优化:因果 sum-to-zero 直接赋值、PNL0001 循环不变量/摩阻不变量与 equation-level 直接 reader 均保留完整残差审计、显式 capability 门和 opt-out;短 A/B 逐位一致。接触感知容差和半解析 Jacobian 候选因改变接触瞬态或收益不足未升为默认,正式配置继续使用 `legacy` 机械容差与 SciPy Jacobian。
- worker/API 效果:当前 production worker `0.2 s / 0.001 s` 为 `147.634 s`,相对旧批准基线 `159.607 s` 缩短 `7.50%`;真实流式 API 于 `147.299 s` 完成。普通进度最长空窗由 `70.369 s` 降到 `57.185 s`,但空窗内 activity 持续推进。
- 真实浏览器:隔离 Chromium→Vite→FastAPI 链路使用同一 JSON,`BDF / 0.2 / 0.01 / 0.001 s` 于 `156.136 s` 完成,输出 21 点;无取消请求、stream error 或 page error,activity sequence 从 `25114` 增至 `66670`,13 个 heartbeat 均携带活动证据。存在一条无关资源 404 控制台消息,不影响仿真验收。
- 当前边界:线程内取消仍不能硬杀永不返回的 native 调用;尚无断流重连;实时 `jacobianEvaluationCount` 不能看穿 SciPy 内部有限差分构建。它们继续留在 OPT-08,不影响本次“活跃慢步不再被浏览器误杀”的结论。
- 证据:`runs/2026-08-18-production-slow-region-exact-v1-0.2.json`(SHA-256 `34268e58...`)和 `runs/2026-08-18-production-browser-live-activity-v1-0.2.json`(SHA-256 `1f72746c...`)。
## 4. 优化任务总览
优先级定义:`P0` 为基线或正确性前置,`P1` 为主要性能收益,`P2` 为第二阶段,`P3` 为战略性或条件性工作。
| ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 |
| --- | --- | --- | --- | --- | --- | --- |
| OPT-00 | P0 | 固化复现、环境和回归基线 | 重新打开(离线基线完成;浏览器路径失败待定位) | 中 | 很高 | 无 |
| OPT-00 | P0 | 固化复现、环境和回归基线 | 已完成(本地 P0 基础闭环;远端 CI 运营证据待补) | 中 | 很高 | 无 |
| 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 | P0/P1 | 最大积分步长路径鲁棒性、状态缩放和步长策略 | 进行中(P0 阻断) | 中高 | 很高 | OPT-00 |
| OPT-05 | P0/P1 | 最大积分步长路径鲁棒性、状态缩放和步长策略 | 进行中(0.2/1/2 s 可解性主阻断解除;分层契约与 5/10 s 待续) | 中高 | 很高 | OPT-00 |
| OPT-06 | P2 | 事件检测与 dense output 按需化 | 部分实现 | 中 | 中 | OPT-00 |
| OPT-07 | P2 | 输出、后处理和传输内存优化 | 未开始 | 中 | 中高(长仿真) | OPT-00 |
| OPT-08 | P0/P2 | 进度、取消和服务并发鲁棒性 | 进行中(浏览器 `0.0489 s` 失败待定位) | 中 | 很高 | OPT-00、OPT-05 P0 门 |
| OPT-09 | P0/P2 | 建立 10 s 长时验证与模式覆盖 | 进行中(权威 `0.001 s` 基线未完成) | 中高 | 很高 | OPT-00、OPT-05 P0 门、OPT-08 服务门 |
| OPT-08 | P0/P2 | 进度、取消和服务并发鲁棒性 | 部分实现(`0.0489 s` 假超时闭环;真停滞、断连与并发仍待) | 中 | 很高 | OPT-00、OPT-05 P0 门 |
| OPT-09 | P0/P2 | 建立 10 s 长时验证与模式覆盖 | 进行中(0.2/1/2 s 已完成;5 s 中止无报告;权威 10 s 未运行) | 中高 | 很高 | OPT-00、OPT-05 P0 门、OPT-08 服务门 |
| OPT-10 | P3 | 明确高指数 DAE/强非光滑系统边界 | 未开始 | 很高 | 条件性 | OPT-09 |
当前推荐实施顺序:`OPT-00 浏览器失败复现 → OPT-05/OPT-08 定位并解除 0.2 s 阻断 → 0.2/1 s × 五档 maxStep 短时鲁棒性门 → 2/5/10 s 逐级延长 → OPT-09 首次 10 s / 0.001 s 完整基线 → 基于时间线提案、审阅、单项优化与完整复验`。OPT-01/02/03/04 的既有成果保留,但在该 P0 工程门通过前不以单点性能收益替代鲁棒性验收。
当前顺序:`OPT-00 本地基础闭环已收口 → OPT-05 将跨步长比较器改为分层契约 → OPT-08 补真停滞/断连/并发边界 → 按用户要求暂停 5/10 s → 恢复时先生成完整 5 s 报告,再运行 OPT-09 的 10 s / 0.001 s 权威基线`。OPT-01/02/03/04 的既有成果保留;任何后续收益都不得替代分类正确性与长时验收。
## 5. 详细任务
@@ -202,7 +231,7 @@
**目标**:先让“是否更快、是否仍正确、是否又卡住”可以稳定复现和自动判断。
**当前状态**:重新打开工程端到端复核。2026-08-18 的离线 production runner、发布锁、golden、physical-state-v2.1、历史 `2.10 s` 三次复测和本地自动测试证据继续有效;但浏览器使用权威 JSON、BDF、`tStop=0.2 s`、`sampleStep=0.01 s`、`maxStep=0.001 s` 时当前会在 `t≈0.0489 s` 被判定计算超时,工程验收结果为失败。离线 runner 的完成记录不能替代浏览器结果;同 `maxStep` 的时域延长不变量和减小 `maxStep` 的可解性不变量也尚未验收。
**当前状态**:已完成本地 P0 基础闭环。2026-08-18 的 production runner、发布锁、golden、physical-state-v2.1、历史 `2.10 s` 三次复测和本地自动测试证据继续有效;2026-08-19 又用真实浏览器完成权威 JSON 的 `BDF / tStop=0.2 s / sampleStep=0.01 s / maxStep=0.001 s`,消除了 `t≈0.0489 s` 的假超时。`0.2/1 s × 五档 maxStep` 的 10 个单元全部完成,同 `maxStep` 严格公共前缀通过。跨步长分层契约、真停滞硬终止和 5/10 s 长时验证分别继续归 OPT-05、OPT-08、OPT-09,不再阻塞 OPT-00 的本地基础设施收口;远端 CI 首次运营证据仍待提交后补充。
**工作项**:
@@ -216,15 +245,17 @@
- [x] 将无数值的完整输出形状契约与物理状态 golden 分开;完整 API 序列化契约若需逐字段稳定性,后续另行定义。
- [x] 建立短 CI、夜间 `0.81/2.10 s`、定期递进至 `10 s` 的三层 workflow;远端首次执行待提交后确认。
- [x] 保存带环境、仓库、输入、运行统计和验收结果的机器可读 JSON 报告。
- [ ] 以权威 JSON 经浏览器生成 XML 并走流式 API 的真实路径完成 `0.2 s / 0.001 s`,与 production worker 对账输入哈希、生效参数、事件、物理量和最终结果;定位并消除 `t≈0.0489 s` 的计算超时或假超时。
- [ ] 通过第 3.4 节的 `0.2/1 s × 五档 maxStep` P0 短时矩阵与两条不变量;既有 `0.01/0.02/0.05/0.10 s` 历史运行不能替代缺失的 `0.001 s` 工程验收。
- [x] 以权威 JSON 经浏览器生成 XML 并走流式 API 的真实路径完成 `0.2 s / 0.001 s`,与 production worker 对账输入、生效参数和结果;定位并消除前端对 `t≈0.0489 s` 活跃慢区的假超时。
- [x] 完成第 3.4 节的 `0.2/1 s × 五档 maxStep` P0 单元可解性门及同 `maxStep` 时域延长不变量;跨 `maxStep` 的自动分层数值契约继续归 OPT-05。
**验收条件**:
- [x] 干净环境可按发布锁一条安装命令复现:全新空 venv 使用 22 个锁定 wheel 与 SHA-256 完成安装,`pip check`、锁定环境契约和最终 quick workflow 同口径测试通过。
- [x] 正式 production 环境严格串行 3 次完成 `0.81/2.10 s`;两档检查点、状态、事件、输出契约和除计时外的诊断逐值一致,无非有限值或非预期回退。
- [x] 新主目标 `0.2 s` 性能报告完整记录环境、提交、工作树、输入哈希和统计口径。
- [ ] 浏览器/服务/worker 三条路径对权威 `0.2 s / 0.001 s` 均能完成且不会发生无证据的 60 s 假停滞;真实无活动仍能有界退出并提供诊断。
- [x] 浏览器、流式服务和 worker 三条路径对权威 `0.2 s / 0.001 s` 均能完成;内部活动持续时不再发生无证据的 60 s 假停滞。
范围边界:真正无活动或单次 native 调用永不返回时的硬终止属于 OPT-08 服务隔离验收,不再作为 OPT-00 基线基础设施的完成条件。
**前后对比**:
@@ -519,7 +550,7 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
**目标**:让无环 stream 网络一次传播,只对真正的强连通块迭代;同一状态反算的物性量成组计算和复用。
**当前状态**: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 传播与物性成组复用尚未实现。
**当前状态**:stream 求解器已预绑定组件、端口和连接,物性层也有单次运行精确缓存;但每次求解仍构造临时字典/列表、重复调用连接焓计算,尚未编译 SCC/DAG。热流体外层固定点上限仍为 25 次:production `0.2 s` 实测最多 3 次;2026-08-17 较大 `maxStep` 的历史延长运行在 `2/5/10 s` 实测最多 18–23 次,修复前在 `t≈1.8595–1.8603 s` 会耗尽 25 次。当前已补充试探点事务回滚和类型化可恢复失败,并由 StreamResolver 为所有覆盖温度参考更新钩子的组件统一刷新连接参考;SCC/DAG 传播与物性成组复用尚未实现。
**工作项**:
@@ -542,7 +573,7 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
| 指标 | 当前 | 完成后 |
| --- | ---: | ---: |
| stream 块 / 未知量 | 9 / 192 | 待填 |
| 最大热流体迭代 | production 0.2 s:3;修复后 2/5/10 s:18–23;恢复阈值:25 | 待填 |
| 最大热流体迭代 | production 0.2 s:3;2026-08-17 较大 maxStep 历史 2/5/10 s:18–23;恢复阈值:25 | 待填 |
| `2.10 s` 压力闭合 | 57,601 | 待填 |
| 物性调用 / 缓存命中率 | 待测 | 待填 |
@@ -550,7 +581,7 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
**目标**:首先保证在合理 `maxStep` 工程区间内,步长上限只影响可解释的误差和成本,而不决定仿真能否完成;随后再减少量纲差异造成的不必要小步和 Jacobian 重建,同时维持事件与守恒精度。
**当前状态**:P0 阻断、先定位。模型中不同物理量的量级差异大。历史试验显示机械绝对容差放宽可能带来约 16% 收益,但属于精度策略变化;热流体固定点容差的简单放宽曾使表现变差,不能直接采用。当前已先完成不改变容差契约的可恢复试探步:按积分器实际 `h_abs` 对半退避,并在首次接受后恢复分段步长上限。Generic 显式 opt-in,即使模型无状态事件、断点或取消回调,也会进入支持重建的 stepwise 路径;普通 `integrate_ode` 调用者的默认路径不变。权威浏览器场景 `BDF / 0.2 s / sampleStep=0.01 s / maxStep=0.001 s` 当前在 `t≈0.0489 s` 计算超时,已构成工程失败;尚须用同 JSON 的 browser/API/worker A/B 定位其属于数值热点、服务假超时或二者叠加。既有 `2 s` 矩阵没有覆盖 `0.001 s`,也未系统验证时域延长与步长细化不变量,不能替代本次 P0 门。
**当前状态**:进行中,但 0.2/1/2 s 的可解性主阻断已经解除。原浏览器 `t≈0.0489 s` 问题已分解为“前端 accepted-progress 误杀”和“8 个高刚度接触引发的大量纳秒级 BDF 微步”;activity-aware watchdog 已修复前者,保持数值语义的因果/PNL 热路径优化降低了后者的工作量。`0.2/1/2 s × 五档 maxStep` 共 15 个单元全部完成,0 次恢复重试,且 `0.2→1→2 s` 同 `maxStep` 的严格公共前缀逐位一致;没有出现更小 `maxStep` 独有的不可恢复失败。任务仍未完成,因为 runner 尚未自动分层积分状态、派生量和物理投影,2 s 接触后的近零 `v/a` 与 `1.85–1.90 s` 流量换向仍有跨步长敏感性,5/10 s 也尚未完成。
**工作项**:
@@ -560,12 +591,14 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
- [ ] 统计限制步长的状态分量、误差拒步和 Jacobian 重建原因。
- [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。
- [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。
- [ ] 用完全相同的 JSON 生成请求,对浏览器流式路径与 production worker 做同参 A/B,逐层核对 requested/effective `tStop/sampleStep/maxStep/method`。
- [ ] 围绕 `t=0.04–0.05 s` 按模拟区间记录 BDF 阶次、实际 `h_abs`、接受/拒绝步、重启、Jacobian/RHS/闭合耗时及最坏组件,定位 `0.0489 s` 的首次性能悬崖。
- [ ] 在改变算法前提交定位报告与候选方案供审阅,明确证据、影响面、正确性风险、预期计数变化和回退方式;获准后只实施一个概念,并以原失败单元及同一行/列相邻配置做 A/B。
- [ ] 审计失败试探的事务恢复、缓存、端口和离散模式,证明失败点不会污染下一条缩步路径。
- [x] 用完全相同的 JSON 生成请求,对浏览器流式路径、真实 API 与 production worker 做同参 A/B,逐层核对 requested/effective `tStop/sampleStep/maxStep/method`。
- [x] 围绕 `t=0.04–0.05 s` 记录接受步、Jacobian/RHS/闭合工作量并定位首次性能悬崖到 8 个 `LSTP00A` 高刚度接触的微步簇。
- [ ] 补齐逐 `0.01 s` 的 BDF order、全程 `h_abs`、拒步与 SciPy 内部有限差分 Jacobian 阶段时间线;现有实时 `jacobianEvaluationCount` 只覆盖显式 callable Jacobian。
- [x] 在改变算法前提交定位报告与候选方案供审阅,明确证据、影响面、正确性风险、预期计数变化和回退方式;获准后逐概念 A/B,拒绝了改变轨迹或收益门未过的候选。
- [x] 审计失败试探的事务恢复、缓存、端口和离散模式;故障注入证明失败后可重放,失败诊断保留且不会污染下一条缩步路径。
- [ ] 只在证据支持时对事件、接触、流向或闭合边界使用局部步长上限、有界缩步或模式感知策略;禁止靠全程硬编码某个“幸运” `maxStep` 收口。
- [ ] 将第 3.4 节的 `0.2/1 s × 五档` 二维矩阵设为每次步长/缩放改动的 P0 回归;短时通过后再按 `2/5/10 s` 逐级验证公共前缀。
- [x] 完成第 3.4 节的 `0.2/1 s × 五档` 短时矩阵和 `2 s × 五档` 延长矩阵;15 个单元全部完成,严格公共前缀验证到 2 s。
- [ ] 恢复时继续生成可签收的 5 s 报告和权威 10 s 报告,并将分层矩阵设为每次步长/缩放改动的 P0 回归。
- [x] 对可恢复的热流体闭合失败使用积分器实际试探步 `h_abs` 对半回退;最多 16 次且不低于 64 ULP,恢复步仅设置 `first_step`,首次接受后恢复分段 `maxStep` 上限并记录 attempted/next step。
- [x] 为 eventless Generic 显式启用 `recoverable_trial_retries`,使没有状态事件、断点或取消回调的通用模型也能选择 stepwise 恢复;该参数默认关闭,避免改变其他调用者的直接 `solve_ivp` 语义。
@@ -576,6 +609,23 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
- [ ] 所有收益报告同时给出误差变化,禁止只报告墙钟。
- [ ] 满足第 3.4 节 P0 门:短时二维矩阵满足时域延长与步长细化不变量,事件/模式/守恒满足分类契约,任何性能悬崖都有可复现的阶段与组件归因。
#### 2026-08-19 / 权威五档短时与 2 s 矩阵
所有单元均为 production lane、BDF、`sampleStep=0.01 s`,并只在内存覆盖 `tStop/maxStep`:
| `tStop` | `maxStep=.001` | `.002` | `.005` | `.01` | `.02` |
| ---: | ---: | ---: | ---: | ---: | ---: |
| 0.2 s | 145.942 s | 166.070 s | 156.667 s | 156.431 s | 157.374 s |
| 1 s | 198.351 s | 214.308 s | 210.164 s | 214.616 s | 209.664 s |
| 2 s | 348.040 s | 306.140 s | 333.424 s | 341.867 s | 335.172 s |
- 完成性:15/15 单元到达终点,单元 `matrixAcceptance.passed=true`;无 soft/hard timeout、NaN/Inf、热流体失败或恢复重试。最大缩放残差为 `9.56e-17–1.09e-16`。
- 时域不变量:五个 `maxStep` 的 `0.2→1 s` 与 `1→2 s` 严格公共前缀逐位一致;短任务终点不参与严格前缀比较。2 s 的两次机械事件顺序一致,时刻最大跨度 `9.57425e-6 s`,小于 `2e-5 s` 门限。
- 分层结果:0.2 s 的 9/10、1 s 的 10/10、2 s 的 10/10 跨步长 pair 在旧顶层比较器中为红,但没有单元失败。0.2/1 s 红项全部是派生 `a`;2 s 为 1111 个 `a` 与 720 个事件后近零 `v`,`x` 及其余状态无超差。physical-state-v2.1 的压力、守恒和离散模式通过;流量差异集中在 `t=0.04 s` 左右极限和 2 s 的 `1.85–1.90 s` 换向区。
- 性能结论:耗时随 `maxStep` 非单调,0.2/1 s 单次最快为 `.001`,2 s 单次最快为 `.002`;不能据单次结果选择“幸运步长”或修改正式默认值。
- 检查点边界:早期 0.2 s 报告中的请求 `.048/.0489 s` 实际映射到输出网格 `.05 s`,不得作为精确慢区检查点;runner 现已拒绝 off-grid 检查点,慢区使用 activity/step trace 取证。
- 证据:`runs/2026-08-18-production-0.2s-max-step-robust-v1.json`(SHA-256 `ec5480af...`)、`runs/2026-08-18-production-1s-max-step-robust-v1.json`(`1f1c639b...`)和 `runs/2026-08-18-production-2s-max-step-robust-v1.json`(`93367d0f...`)。
### OPT-06 事件检测与 dense output 按需化
**目标**:避免在绝大多数没有事件候选、也不跨输出采样点的接受步上创建 dense output。
@@ -599,7 +649,7 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
**目标**:在长仿真中控制结果生成、JSON 编码、前端复制和峰值内存。
**当前状态**:当前模型有 1,021 个结果变量;`10 s / 0.01 s` 约产生 1,022,021 个标量。现路径会对每个样本重新闭合、追加全部结果,并把完整结果作为一个 NDJSON 消息发送。它不是本次 2.05 s 慢推进的主因,但会成为长时间运行的显著成本。
**当前状态**:历史复杂 XML 有 1,021 个结果变量;当前主目标有 1,784 个结果变量,加时间轴共 1,785 条序列。`10 s / 0.01 s` 的 1001 个采样点预计产生 1,786,785 个标量。现路径会对每个样本重新闭合、追加全部结果,并把完整结果作为一个 NDJSON 消息发送。它不是本次接触慢区的主因,但会成为长时间运行的显著成本。
**工作项**:
@@ -621,17 +671,18 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
**目标**:区分“内部慢步”和“真正无进度”,并让长任务可取消、可限流、不会拖垮服务进程。
**当前状态**:P0 服务门进行中。已有 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 矩阵不再把“没有跨步长比较对”误判为失败。权威 `0.2 s / 0.001 s` 浏览器运行当前在 `t≈0.0489 s` 计算超时,浏览器工程门已经失败;但根因尚未确定,必须追踪前端 60 s 计时器、NDJSON 心跳、API worker 与内部求解活动,不能直接等同于求解器死锁。
**当前状态**:部分实现,浏览器 P0 假超时已闭环。后端现在分别上报 accepted progress 与 RHS/solver step/热流体闭合等内部活动,5 s heartbeat 携带 activity 快照;前端在 `integrating` 阶段有活动遥测时,仅在 accepted 和 activity 同时连续 60 s 不变后请求停止,活动继续增长时保持运行;缺少活动遥测的旧后端使用 15 分钟保守兜底,30 s 完全无字节的断流门不变。真实浏览器 `0.2 s / 0.001 s` 已完整到达终点,原 `0.0489 s` 慢区内 activity 持续增长且未触发取消。剩余边界是:线程内 cooperative cancel 不能硬杀永不返回的 native/Python 调用,客户端断流不能重连到原任务,尚无并发 worker/队列/资源租约的完整门控,SciPy 内部有限差分 Jacobian 也不能由当前实时字段精确分类。
**工作项**:
- [ ] 分别上报模拟时间、接受步、内部 RHS/闭合活动和墙钟心跳。
- [ ] 将“运行中但步很慢”与“求解器无活动”使用不同状态和超时策略。
- [x] 分别上报模拟时间、接受步、内部 RHS/solver step/闭合活动和墙钟心跳。
- [x] 将“运行中但步很慢”与“求解器无活动”使用不同状态和超时策略;缺少新 telemetry 的旧后端也不会被前端自动误杀。
- [ ] 在代数闭合、stream 迭代、Jacobian 构建和后处理内加入有界取消检查。
- [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。
- [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。
- [ ] 添加故意慢 RHS、死循环防护、客户端断连和多任务竞争测试。
- [ ] 将活动心跳从“接受新积分步”扩展到 RHS、Jacobian 构建、stream/热流体闭合和恢复循环;至少携带 phase、wall time、last accepted time、current RHS time、`h_abs`、`nfev` 与闭合迭代。
- [x] 将活动心跳扩展到 RHS、solver step、显式 Jacobian、stream/热流体闭合和恢复循环,并携带 activity sequence/kind、current trial time、RHS/accepted/solver/Jacobian/closure 计数。
- [ ] 补充实时 `h_abs`、BDF order、SciPy 内部有限差分 Jacobian 阶段和任务级 CPU delta;当前 `jacobianEvaluationCount` 不能代表 SciPy 内部 `njev`。
- [ ] 验证客户端流断开、浏览器本地 watchdog 和显式取消的语义不同;客户端误判或断连不得在无用户授权时静默丢失仍健康运行的 worker 结果。
- [x] 热流体失败记录 RHS 时刻、最近迭代尾部、最大增量/尺度/容差、最差端口及带符号差值,并保留求解器逐次恢复的 attempted/next step 与原因。
- [x] 矩阵报告分别记录外层 `soft_timeout` 和 worker 的合作 `cancelled`,避免把预算取消误记为求解器数值失败。
@@ -639,22 +690,25 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
**验收条件**:
- [ ] 正常慢步不会被误判为死锁,真实无活动能在约定时间内终止并给出诊断。
- [x] 正常活跃慢步不会被误判为死锁;真实浏览器在原慢区持续收到活动 heartbeat 并正常完成。
- [ ] 真实无活动或单次调用永不返回时,能在约定时间内硬终止并给出诊断。
- [ ] 取消请求在每个主要阶段都能在有界时间内生效。
- [ ] 并发压力下服务仍能响应健康检查和新请求拒绝/排队逻辑。
- [ ] 权威 `0.2 s / 0.001 s` 浏览器路径完成且不发生假超时;只要内部活动持续,15 分钟无接受步不得触发 `SOLVER_STALLED`,真正无活动仍能按约定上限停止并返回最后阶段与计数。
- [x] 权威 `0.2 s / 0.001 s` 浏览器路径完成且不发生假超时;有活动遥测时仅 accepted 与 activity 同时连续 60 s 不变才判停,内部活动持续时保持运行;缺少活动遥测时采用 15 分钟兼容兜底。
### OPT-09 建立 10 s 长时验证与模式覆盖
**目标**:用权威 JSON 的 `BDF / tStop=10 s / sampleStep=0.01 s / maxStep=0.001 s` 完整实测,替代“短仿真或较大 `maxStep` 可以外推到最终工程场景”的假设。
**当前状态**:权威 `0.001 s` 长基线尚未完成。既有 `10 s / maxStep=0.02 s` 只作为算法可行性、恢复机制和历史性能证据,不能签收当前 JSON 的工程基线。此前新主目标的 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` 不是直接失败原因,它只决定运行是否到达该局部数值困难区。
**当前状态**:进行中,当前代码的权威 `10 s / 0.001 s` 长基线尚未运行。权威五档已经全部完成到 `2 s`,同 `maxStep` 的 `0.2→1→2 s` 严格公共前缀逐位一致;这支持“延长 tStop 不改变已覆盖轨迹”。2026-08-19 的 5 s 三档尝试约于 `15:14:14 UTC` 启动:`.001` 子进程约 `15:24:09` 结束并进入 `.005`,约 `9 分 55 秒`;`.005` 运行约 60 秒后按用户要求中止,`.02` 未启动。由于 runner 只在整组完成后落盘,本次没有 5 s 聚合报告,不能把 `.001` 写成正式通过。既有 `10 s / maxStep=0.02 s` 只作为历史算法可行性与恢复证据,不能签收当前 JSON 的工程基线。
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。
以下 2026-08-17 的结果均为历史恢复与接线证据,不代表当前 `.001 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 墙钟或预算 | 可恢复重试 | 说明 |
| 历史 `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 |
@@ -687,14 +741,15 @@ PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复
**工作项**:
- [ ] 在 OPT-00 的 `0.2/1 s` 短时二维门通过后,使用同一 JSON 和 `maxStep=0.001 s` 依次延长到 `2/5/10 s`;在任何针对本次长跑的性能调优前先完整运行一次权威 `10 s / 0.001 s` 原始基线。
- [x] 在 OPT-00 的 `0.2/1 s` 短时门通过后,使用同一 JSON 和五档 `maxStep` 延长到 `2 s`;5 个单元全部完成且 0 次恢复重试。
- [ ] 按用户要求暂停后,恢复时从头生成完整 5 s 报告,再运行权威 `10 s / 0.001 s` 当前优化版本基线。
- [ ] 首次长基线不得因总墙钟较长而提前当作性能失败;只有 worker、CPU 和内部活动心跳均停止并满足真停滞条件时才有界终止。若发现致命正确性问题,只做使基线可完成的最小修复,然后从 `t=0` 重新运行。
- [ ] 在正式锁定环境运行未优化基线 `10 s`,设置心跳、资源上限和可恢复日志。
- [ ] 在正式锁定环境运行当前优化版本基线 `10 s`,设置心跳、资源上限和可恢复日志;用户已明确授权在首次 10 s 前先解决 70 s 慢区。
- [ ] 保存事件、模式、步长、拒步、Jacobian、闭合和内存随模拟时间的时间线。
- [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。
- [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。
- [ ] 根据首次基线制定合理的 CI 频率和资源门槛。
- [x] 最终通用接线后的当前工作树完成首次 `10 s / maxStep=0.02 s` 单次运行并保存完整统计;连续 3 次验收仍待后续。
- [x] 2026-08-17 最终通用接线版本完成一次历史 `10 s / maxStep=0.02 s` 运行并保存完整统计;它不计入当前权威基线。
- [ ] 首次 `10 s / maxStep=0.001 s` 完整报告生成后,才根据各阶段墙钟与内部计数决定性能优化目标;旧 `0.02 s` 报告不得用于跳过该顺序。
**验收条件**:
@@ -737,13 +792,13 @@ PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复
| 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 主目标 `test-mql-8` `0.2 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 主目标 `test-mql-8` `1/5/10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 主目标 `test-mql-8` `1/2/5/10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
| 权威 JSON 浏览器/流式 API `0.2 s / maxStep=0.001 s` | 必测 | 必测 | 必测 | 必测 | 必测(含内部活动心跳) | 可选 |
| 权威 worker `0.2/1 s × maxStep={0.001,0.002,0.005,0.01,0.02} s` | 必测 | 必测(分类容差) | 必测 | 必测 | 必测(串行矩阵) | 可选 |
| 权威 worker `0.2/1/2 s × maxStep={0.001,0.002,0.005,0.01,0.02} s` | 必测 | 必测(分类容差) | 必测 | 必测 | 必测(串行矩阵) | 可选 |
| 权威 JSON `10 s / maxStep=0.001 s` | 必测 | 必测 | 必测 | 必测 | 必测(完整时间线) | 必测 |
2026-08-17 的 `maxStep=0.01/0.02/0.05/0.10 s` 延长结果继续作为恢复机制与历史路径证据,但不替代
2026-08-18 权威 `maxStep=0.001 s` 的 browser/API/worker P0 门和 10 s 最终基线。
当前权威 `maxStep=0.001 s` 的 browser/API/worker 0.2 s 门已经完成,但历史结果仍不能替代尚未运行的 10 s 最终基线。
当前相关回归套件包括:
@@ -759,7 +814,8 @@ PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复
- `tests/test_thermofluid_closure_plan.py`
- `tests/test_max_step_matrix.py`
这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。最终完整 `unittest discover` 共 828 项,OK(3 项跳过)。
这些测试目前覆盖部分关键机制,但不能替代复杂 XML 的端到端数值和长时回归。2026-08-19 当前工作树完整
`unittest discover` 共 896 项,OK(3 项跳过);前端 activity watchdog 聚焦测试 8/8、真实 live 浏览器 E2E 1/1 通过。
## 7. 单项更新模板
@@ -796,6 +852,9 @@ PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复
| 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 仍待后续 |
| 2026-08-18 | 工作树基于 `684d287`;AME SHA `cbc3aadd...` | OPT-00 完成 | AME→XML/JSON 权威契约、22 包发布锁、双 golden、最终 replay 与历史 2.10 s 三次复测 | AME 25 项外部评估通过;状态 426/426、物理 33/33 本地重放零误差;quick 179、全量 849 项通过 | 0.2 s worker 159.607 s;2.10 s 三次 113.497–115.868 s | OPT-00 本地验收完成;1 s 预算内 eligible,长时递进转 OPT-09;远端 CI 待提交触发 |
| 2026-08-18 | 同一权威 AME/XML/JSON 工作树 | OPT-00/05/08/09 步长鲁棒性重新打开 | 浏览器在 `BDF / 0.2 s / sampleStep=0.01 s / maxStep=0.001 s` 下于 `t≈0.0489 s` 计算超时,当前工程路径判定失败;新增 browser/API/worker 对账、`0.2/1 s × 五档` 短时矩阵、内部活动心跳和权威 10 s 门 | 失败事实已确认,具体根因尚未区分为数值真停滞、内部慢步、后处理/传输或 60 s 服务假超时;离线 OPT-00 证据保留但不足以签收浏览器工程路径 | 暂不使用旧 `0.02 s` 长跑推断 `0.001 s`;先定位并提交方案审阅,短时门通过后再完整取得未经本次性能优化的 `10 s / 0.001 s` 基线 | OPT-00 工程端到端门重新打开;OPT-05 提升为 P0/P1,OPT-08 为 P0 服务门,OPT-09 只认 `0.001 s` 权威长基线 |
| 2026-08-18 | 同一工作树;API 诊断任务 `diag-opt00-api-20260818` | OPT-00/05/08 步骤 1–3 定位 | 同参 API `164.954 s` 完成;普通进度 `0.048668→0.049248 s` 间隔 `70.369 s`,期间 5 s heartbeat 与约 99% 单核 CPU 持续 | 参数未改写、0 数值/恢复失败;浏览器在第 `60.416 s` heartbeat 必然先触发 `SOLVER_STALLED`,确认“前端误杀 + 后端真实慢区” | 暂不修改数值算法;建议先把 accepted 平台期改为慢步警告,并增加 activity telemetry,再用 step/RHS/Jacobian/闭合增量定位慢区 | 修改意见已提交待审;在获批前停止后续修复和延长测试 |
| 2026-08-19 | 同一权威工作树;真实浏览器与精确慢区优化 | OPT-00/05/08 本地 P0 收口 | activity telemetry 与 activity-aware watchdog;定位 8 个高刚度 LSTP 接触微步簇;因果 direct-sum/direct-reader 与 PNL 循环不变量;改变轨迹或收益不足的容差/Jacobian 候选未启用 | worker/API/browser 均完成 `0.2 s / 0.001 s`;浏览器 21 点、0 cancel/stream/page error,activity `25114→66670`;AMESim physical-state-v2.1 通过;后端 896 项 OK(3 skip),前端 watchdog 8/8、live E2E 1/1 | worker `159.607→147.634 s`(`-7.50%`);API 147.299 s;浏览器 156.136 s;普通进度最大空窗 `70.369→57.185 s` | OPT-00 本地基础闭环完成;OPT-08 的活跃慢步误杀关闭,真停滞硬杀/断连/并发仍待 |
| 2026-08-19 | 同一工作树;0.2/1/2 s 五档串行矩阵 | OPT-05/09 步长与时域鲁棒性 | `maxStep={.001,.002,.005,.01,.02}` 的 15 个单元全部完成;严格公共前缀验证 `0.2→1→2 s`;runner 拒绝 off-grid 检查点 | 15/15 单元通过、0 timeout/NaN/热流体失败/恢复重试;顶层 comparison 红项分层为 0.2/1 s 派生 `a`,2 s 接触后近零 `v/a` 与局部流量换向;压力/守恒/模式/事件通过 | 0.2 s 为 145.942–166.070 s;1 s 为 198.351–214.616 s;2 s 为 306.140–348.040 s,耗时对 maxStep 非单调 | 可解性与时域延长主阻断解除;自动分层契约仍待。5 s 首格进程约 595 s 后转入第二格,第二格约 60 s 时按用户要求中止且无聚合报告;10 s 未启动 |
## 9. 相关文档
@@ -23,6 +23,14 @@
- 最终 quick workflow 同口径 179 项通过(2 项预期跳过),完整后端 849 项通过(3 项预期跳过),`git diff --check` 与残留进程检查进入最终收尾。
- OPT-00 已完成当时工作树的本地验收。仍存在的问题:批准报告来自 `684d287` 的脏工作树;后续求解器提交合入后尚未重新运行真实 production 基线和全量回归,远端 workflow 也待提交后首次托管验证。
## 23:10
- 统一物理回归基线口径:AMESim 仿真结果成为唯一物理数值基线,production runner 每次运行都重新输出当前值、AMESim 基线值、绝对误差和相对误差;Python exact/state golden 降为确定性与实现漂移诊断,不再参与物理正确性批准,但输出契约变化仍会阻断验收。
- 对 AMESim 零基线不再使用人为最小分母:相对误差明确记为 `null` 并由绝对误差门判定;无 AMESim 数据的内部守恒量完整记录为基线不可用,并继续执行独立绝对残差门;通用 AMESim 时序比较 CSV 同步采用该零基线语义。
- manifest loader 现在校验 AMESim 权威归档的角色、仓库内路径、字节数和 SHA-256,并与 physical-state artifact 的 AMESim provenance 交叉绑定,防止基线文件或引用静默漂移。
- 用同步远端元件修正后的真实 production `0.2 s` 报告复核新门禁:33 条指标均记录,27 条具有 AMESim 基线,其中 25 条参与判定、2 条跳变流量仅记录不判定、6 条内部量走本地不变量门;两类门均通过,最坏相对误差为 `0.139334%`,占 0.2% 包络的比例为 `0.696670`。
- AMESim/runner/主模型等关联回归 67 项通过(3 项按条件跳过),通用 AMESim 比较及上层调用 30 项通过;Python 编译、manifest JSON 校验和 `git diff --check` 均通过。
## 23:21
- 完成当前工作区快照存档并推送至远端 `model-development`,提交为 `a8c7338`;本次纳管 32 个文件,相关 68 项回归测试通过(3 项按条件跳过),差异检查无异常。
@@ -0,0 +1,10 @@
# 更新日志 2026-08-20
## 00:23
- 同步远端 `model-development` 的 3 个提交并完成语义合并:保留 PNL0003 的 AMESim Reynolds 诊断修正、无固定采样点业务上限及运行时安全校验,同时保留本地 PNL 热路径优化、求解器内部活动遥测、浏览器活跃慢步识别和对应回归,未覆盖任一侧成果。
- 统一浏览器停滞判定:有活动遥测时,仅在接受步和内部活动同时连续 60 秒不变后判停;内部活动持续时保持运行;缺少活动遥测的旧后端采用 15 分钟兼容兜底,30 秒完全无字节的断流门保持不变。
- 整理并纳管权威八路模型 `0.2/1/2 s × maxStep={0.001,0.002,0.005,0.01,0.02} s` 的运行证据:15/15 个单元到达终点,无超时、NaN/Inf、热流体失败或恢复重试,同一 `maxStep` 的 `0.2→1→2 s` 严格公共前缀一致;顶层旧统一比较器仍因接触后近零派生量和局部流量换向报告差异,因此不记为矩阵整体通过。
- 纳管慢区归因、真实 worker/API/浏览器复验及未启用候选的历史报告;最终默认方案保持 `legacy` 机械容差和 SciPy Jacobian。本地证据中 production worker `0.2 s / 0.001 s` 相对旧批准基线缩短约 `7.50%`,AMESim 物理门保持通过,改变轨迹或收益不足的接触感知容差与半解析 Jacobian 候选未启用。
- 合并后的完整后端 900 项测试通过,其中 3 项按条件跳过;前端两套 TypeScript 检查、Vite 生产构建及 12 项活动看门狗/超时回归通过,差异格式检查无异常。
- 仍存在的问题:跨 `maxStep` 的自动分层比较器尚未完成;当前权威 `5 s` 聚合报告和 `10 s / 0.001 s` 基线尚未生成;真正无活动调用的硬终止、客户端断连恢复、并发资源门控及 SciPy 内部有限差分 Jacobian 的实时分类仍待实现。
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig } from "@playwright/test";
import { fileURLToPath } from "node:url";
const channel = process.env.PLAYWRIGHT_CHANNEL?.trim();
const executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH?.trim();
const frontendRoot = fileURLToPath(new URL(".", import.meta.url));
const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
const simulationEnvironment = {
...process.env,
SIMULATION_CAUSAL_EXECUTOR_V2: "1",
SIMULATION_CAUSAL_COORDINATE_KERNEL: "1",
SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS: "1",
SIMULATION_CAUSAL_DIRECT_EQUATION_READERS: "1",
SIMULATION_CAUSAL_FAST_PATH: "1",
SIMULATION_MECHANICAL_ATOL_MODE: "legacy",
SIMULATION_ODE_JACOBIAN_MODE: "scipy",
SIMULATIONAPP_PROPERTY_CACHE: "on",
};
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
timeout: 600_000,
expect: {
timeout: 15_000,
},
outputDir: "test-results/live-mql8",
use: {
baseURL: "http://127.0.0.1:14173",
...(channel ? { channel } : {}),
...(executablePath ? { launchOptions: { executablePath } } : {}),
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: "only-on-failure",
trace: "on",
},
webServer: [
{
command:
".venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 18181",
cwd: repositoryRoot,
env: simulationEnvironment,
url: "http://127.0.0.1:18181/api/components/catalog",
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 },
},
{
command:
"node --input-type=module -e \"import { createServer } from 'vite'; import react from '@vitejs/plugin-react'; const server = await createServer({ configFile: false, root: process.cwd(), plugins: [react()], server: { host: '127.0.0.1', port: 14173, strictPort: true, proxy: { '/api': 'http://127.0.0.1:18181' } } }); await server.listen();\"",
cwd: frontendRoot,
url: "http://127.0.0.1:14173",
reuseExistingServer: false,
timeout: 120_000,
gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 },
},
],
});
+223 -22
View File
@@ -109,8 +109,12 @@ import {
lmechn1RightPortCount,
} from "./componentSymbols/mechanical";
import {
SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS,
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES,
SIMULATION_SOLVER_STALL_TIMEOUT_MS,
solverActivityStallTimeoutMessage,
solverStallTimeoutMessage,
solverStallTimeoutReached,
} from "./simulationTimeout";
import {
CONTACT_AWARE_EDGE_TYPE,
@@ -1053,6 +1057,37 @@ type SimulationStreamIssue = {
message?: string;
};
type SimulationActivityTelemetry = {
activitySequence?: number;
activityKind?: string;
currentTrialTime?: number | null;
rhsCallCount?: number;
acceptedStepSequence?: number;
acceptedTime?: number | null;
};
export type SimulationActivityWatchdogStatus =
| "progressing"
| "slow-active"
| "slow-telemetry-unavailable"
| "stalled";
export type SimulationActivityWatchdogState = {
lastAcceptedProgressAt: number;
lastActivityChangeAt: number | null;
lastActivitySequence: number | null;
lastAcceptedStepSequence: number | null;
lastAcceptedTime: number | null;
integratingPhaseActive: boolean;
};
export type SimulationActivityWatchdogObservation = {
status: SimulationActivityWatchdogStatus;
acceptedProgressIdleMs: number;
activityIdleMs: number | null;
activityTelemetryAvailable: boolean;
};
type SimulationCancelReason = "user" | "stalled";
type SimulationTaskResponse = {
@@ -1064,7 +1099,7 @@ type SimulationTaskResponse = {
};
type SimulationStreamEvent =
| {
| ({
event: "progress";
progress: number;
phase: string;
@@ -1072,7 +1107,7 @@ type SimulationStreamEvent =
heartbeat?: boolean;
simulatedTime?: number;
totalTime?: number;
}
} & SimulationActivityTelemetry)
| {
event: "result";
progress: number;
@@ -1092,6 +1127,137 @@ type SimulationStreamEvent =
simulatedTime?: number;
totalTime?: number;
};
export function createSimulationActivityWatchdog(
now = Date.now(),
): SimulationActivityWatchdogState {
return {
lastAcceptedProgressAt: now,
lastActivityChangeAt: null,
lastActivitySequence: null,
lastAcceptedStepSequence: null,
lastAcceptedTime: null,
integratingPhaseActive: false,
};
}
export function isSimulationSolverActivityPhase(phase: string): boolean {
return phase === "integrating";
}
export function observeSimulationActivityWatchdog(
current: SimulationActivityWatchdogState,
event: SimulationActivityTelemetry & { heartbeat?: boolean; phase?: string },
now = Date.now(),
stallTimeoutMs = SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
legacyStallTimeoutMs = SIMULATION_SOLVER_STALL_TIMEOUT_MS,
): {
state: SimulationActivityWatchdogState;
observation: SimulationActivityWatchdogObservation;
} {
const reportsPhase = typeof event.phase === "string";
if (reportsPhase && !isSimulationSolverActivityPhase(event.phase!)) {
return {
state: createSimulationActivityWatchdog(now),
observation: {
status: "progressing",
acceptedProgressIdleMs: 0,
activityIdleMs: null,
activityTelemetryAvailable: false,
},
};
}
const baseline =
reportsPhase && !current.integratingPhaseActive
? {
...createSimulationActivityWatchdog(now),
integratingPhaseActive: true,
}
: current;
let acceptedProgress = event.heartbeat !== true;
let lastAcceptedStepSequence = baseline.lastAcceptedStepSequence;
let lastAcceptedTime = baseline.lastAcceptedTime;
if (
typeof event.acceptedStepSequence === "number" &&
Number.isFinite(event.acceptedStepSequence)
) {
if (
lastAcceptedStepSequence !== null &&
event.acceptedStepSequence !== lastAcceptedStepSequence
) {
acceptedProgress = true;
}
lastAcceptedStepSequence = event.acceptedStepSequence;
}
if (typeof event.acceptedTime === "number" && Number.isFinite(event.acceptedTime)) {
if (lastAcceptedTime !== null && event.acceptedTime !== lastAcceptedTime) {
acceptedProgress = true;
}
lastAcceptedTime = event.acceptedTime;
}
let lastActivityChangeAt = baseline.lastActivityChangeAt;
let lastActivitySequence = baseline.lastActivitySequence;
const reportsActivitySequence =
typeof event.activitySequence === "number" &&
Number.isFinite(event.activitySequence);
if (reportsActivitySequence) {
if (
lastActivitySequence === null ||
event.activitySequence !== lastActivitySequence
) {
lastActivityChangeAt = now;
}
lastActivitySequence = event.activitySequence!;
}
const lastAcceptedProgressAt = acceptedProgress
? now
: baseline.lastAcceptedProgressAt;
const acceptedProgressIdleMs = Math.max(0, now - lastAcceptedProgressAt);
const activityIdleMs =
lastActivityChangeAt === null
? null
: Math.max(0, now - lastActivityChangeAt);
const effectiveStallTimeoutMs = Math.max(0, stallTimeoutMs);
const effectiveLegacyStallTimeoutMs = Math.max(
effectiveStallTimeoutMs,
legacyStallTimeoutMs,
);
let status: SimulationActivityWatchdogStatus = "progressing";
if (acceptedProgressIdleMs >= effectiveStallTimeoutMs) {
if (!reportsActivitySequence || activityIdleMs === null) {
status =
acceptedProgressIdleMs >= effectiveLegacyStallTimeoutMs
? "stalled"
: "slow-telemetry-unavailable";
} else if (activityIdleMs >= effectiveStallTimeoutMs) {
status = "stalled";
} else {
status = "slow-active";
}
}
return {
state: {
lastAcceptedProgressAt,
lastActivityChangeAt,
lastActivitySequence,
lastAcceptedStepSequence,
lastAcceptedTime,
integratingPhaseActive:
reportsPhase || baseline.integratingPhaseActive,
},
observation: {
status,
acceptedProgressIdleMs,
activityIdleMs,
activityTelemetryAvailable: reportsActivitySequence,
},
};
}
const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
const simulationConfigLabels: Record<SimulationNumericKey, string> = {
t_start: "起始时间",
@@ -7355,10 +7521,11 @@ function FlowWorkbench() {
setSimulationStopPending(false);
let lastLoggedPhase = "";
let lastLoggedBucket = -1;
let lastWatchdogStatus: SimulationActivityWatchdogStatus = "progressing";
try {
const result = await streamSystemSimulation(
xml,
(event) => {
(event, watchdog) => {
if (event.event !== "progress") {
return;
}
@@ -7373,14 +7540,38 @@ function FlowWorkbench() {
"initialization",
"initializing",
].includes(event.phase);
const progressMessage =
watchdog?.status === "slow-active"
? `正在处理耗时积分步,内部计算仍活跃${
event.activityKind ? `(${event.activityKind})` : ""
},可手动停止`
: watchdog?.status === "slow-telemetry-unavailable"
? "积分步耗时较长,后端未提供活动遥测;任务保持运行,可手动停止"
: event.message;
setSimulationProgress((current) => ({
...current,
state: "running",
percent: normalizedProgress.percent,
message: event.message,
message: progressMessage,
simulatedTime: normalizedProgress.simulatedTime,
totalTime: normalizedProgress.totalTime,
}));
if (watchdog && watchdog.status !== lastWatchdogStatus) {
if (watchdog.status === "slow-active") {
appendConsoleEntry(
"warning",
`积分步超过 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒尚未接受,但后端内部活动仍在推进;继续等待,可随时手动停止`,
);
} else if (watchdog.status === "slow-telemetry-unavailable") {
appendConsoleEntry(
"warning",
`积分步超过 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒尚未接受,且后端未提供活动遥测;将按 ${SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES} 分钟保守上限继续等待,可手动停止`,
);
} else if (lastWatchdogStatus !== "progressing") {
appendConsoleEntry("info", "求解器已接受新的积分步,仿真继续推进");
}
lastWatchdogStatus = watchdog.status;
}
const progressBucket = Math.floor(normalizedProgress.percent / 10);
if (event.phase !== lastLoggedPhase || progressBucket !== lastLoggedBucket) {
const progressDetail = reportsSimulationTime
@@ -7404,7 +7595,9 @@ function FlowWorkbench() {
appendConsoleEntry(
"error",
error.code === "SOLVER_STALLED"
? solverStallTimeoutMessage("recovering")
? error.message.includes("内部活动")
? solverActivityStallTimeoutMessage("recovering")
: solverStallTimeoutMessage("recovering")
: "超过 30 秒未收到后端数据,正在终止任务并恢复部分结果",
);
try {
@@ -10882,7 +11075,10 @@ async function readSimulationStreamChunk(
async function streamSystemSimulation(
xml: string,
onEvent: (event: SimulationStreamEvent) => void,
onEvent: (
event: SimulationStreamEvent,
watchdog?: SimulationActivityWatchdogObservation,
) => void,
simulationId: string,
): Promise<SimulationResult> {
const abortController = new AbortController();
@@ -10920,7 +11116,7 @@ async function streamSystemSimulation(
const decoder = new TextDecoder();
let buffer = "";
let result: SimulationResult | null = null;
let lastSolverProgressAt = Date.now();
let activityWatchdog = createSimulationActivityWatchdog();
const consumeLine = (line: string) => {
const trimmed = line.trim();
@@ -10936,23 +11132,28 @@ async function streamSystemSimulation(
throw new SimulationStreamError("仿真服务返回了无法识别的进度消息");
}
const event = parsed as SimulationStreamEvent;
let watchdogObservation: SimulationActivityWatchdogObservation | undefined;
if (event.event === "progress") {
if (
event.heartbeat === true &&
solverStallTimeoutReached(lastSolverProgressAt)
) {
throw new SimulationStreamError(
solverStallTimeoutMessage("detected"),
[],
undefined,
"SOLVER_STALLED",
);
}
if (event.heartbeat !== true) {
lastSolverProgressAt = Date.now();
const watched = observeSimulationActivityWatchdog(
activityWatchdog,
event,
);
activityWatchdog = watched.state;
if (isSimulationSolverActivityPhase(event.phase)) {
watchdogObservation = watched.observation;
if (watchdogObservation.status === "stalled") {
throw new SimulationStreamError(
watchdogObservation.activityTelemetryAvailable
? solverActivityStallTimeoutMessage("detected")
: solverStallTimeoutMessage("detected"),
[],
undefined,
"SOLVER_STALLED",
);
}
}
}
onEvent(event);
onEvent(event, watchdogObservation);
if (event.event === "result") {
result = event.result;
return;
+12
View File
@@ -1,6 +1,9 @@
export const SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES = 15;
export const SIMULATION_SOLVER_STALL_TIMEOUT_MS =
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES * 60_000;
export const SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS = 60;
export const SIMULATION_ACTIVITY_STALL_TIMEOUT_MS =
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS * 1_000;
export function solverStallTimeoutReached(
lastSolverProgressAt: number,
@@ -17,3 +20,12 @@ export function solverStallTimeoutMessage(
? `${prefix},正在终止任务并恢复部分结果`
: `${prefix},任务可能已经卡死`;
}
export function solverActivityStallTimeoutMessage(
context: "detected" | "recovering",
) {
const prefix = `求解器的接受步和内部活动均连续 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒没有变化`;
return context === "recovering"
? `${prefix},正在终止任务并恢复部分结果`
: `${prefix},任务可能已经卡死`;
}
@@ -0,0 +1,252 @@
import { expect, test } from "@playwright/test";
import {
createSimulationActivityWatchdog,
isSimulationSolverActivityPhase,
observeSimulationActivityWatchdog,
} from "../../src/App";
const TEST_STALL_TIMEOUT_MS = 1_000;
test("首个含 activitySequence 的慢步心跳只建立活动基线", () => {
const state = createSimulationActivityWatchdog(0);
const watched = observeSimulationActivityWatchdog(
state,
{ heartbeat: true, activitySequence: 42 },
TEST_STALL_TIMEOUT_MS,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("slow-active");
expect(watched.observation.acceptedProgressIdleMs).toBe(TEST_STALL_TIMEOUT_MS);
expect(watched.observation.activityIdleMs).toBe(0);
});
test("accepted time 停滞但 activitySequence 持续增长时保持慢步运行", () => {
let state = createSimulationActivityWatchdog(0);
({ state } = observeSimulationActivityWatchdog(
state,
{
heartbeat: true,
activitySequence: 1,
acceptedStepSequence: 10,
acceptedTime: 0.048,
},
100,
TEST_STALL_TIMEOUT_MS,
));
let watched = observeSimulationActivityWatchdog(
state,
{
heartbeat: true,
activitySequence: 2,
acceptedStepSequence: 10,
acceptedTime: 0.048,
},
1_000,
TEST_STALL_TIMEOUT_MS,
);
state = watched.state;
expect(watched.observation.status).toBe("slow-active");
watched = observeSimulationActivityWatchdog(
state,
{
heartbeat: true,
activitySequence: 3,
acceptedStepSequence: 10,
acceptedTime: 0.048,
},
2_500,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("slow-active");
expect(watched.observation.acceptedProgressIdleMs).toBe(2_500);
expect(watched.observation.activityIdleMs).toBe(0);
});
test("旧后端缺少活动字段时只报告遥测不可用且不误判 stalled", () => {
const state = createSimulationActivityWatchdog(0);
const watched = observeSimulationActivityWatchdog(
state,
{ heartbeat: true },
20_000,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("slow-telemetry-unavailable");
expect(watched.observation.activityIdleMs).toBeNull();
expect(watched.observation.activityTelemetryAvailable).toBe(false);
});
test("旧后端缺少活动字段时使用 15 分钟兼容兜底", () => {
const state = createSimulationActivityWatchdog(0);
const watched = observeSimulationActivityWatchdog(
state,
{ heartbeat: true },
900_000,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("stalled");
expect(watched.observation.activityTelemetryAvailable).toBe(false);
});
test("接受步和明确可用的活动序号都长期不变时才报告 stalled", () => {
let state = createSimulationActivityWatchdog(0);
({ state } = observeSimulationActivityWatchdog(
state,
{ heartbeat: true, activitySequence: 7 },
0,
TEST_STALL_TIMEOUT_MS,
));
const beforeDeadline = observeSimulationActivityWatchdog(
state,
{ heartbeat: true, activitySequence: 7 },
999,
TEST_STALL_TIMEOUT_MS,
);
expect(beforeDeadline.observation.status).toBe("progressing");
const atDeadline = observeSimulationActivityWatchdog(
beforeDeadline.state,
{ heartbeat: true, activitySequence: 7 },
1_000,
TEST_STALL_TIMEOUT_MS,
);
expect(atDeadline.observation.status).toBe("stalled");
});
test("heartbeat 报告新的 acceptedStepSequence 时重置接受步计时", () => {
let state = createSimulationActivityWatchdog(0);
({ state } = observeSimulationActivityWatchdog(
state,
{
heartbeat: true,
activitySequence: 1,
acceptedStepSequence: 10,
acceptedTime: 0.048,
},
0,
TEST_STALL_TIMEOUT_MS,
));
const watched = observeSimulationActivityWatchdog(
state,
{
heartbeat: true,
activitySequence: 2,
acceptedStepSequence: 11,
acceptedTime: 0.049,
},
5_000,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("progressing");
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
expect(watched.state.lastAcceptedProgressAt).toBe(5_000);
});
test("validation 和 compilation 阶段不参与求解器停滞判定", () => {
let state = createSimulationActivityWatchdog(0);
let watched = observeSimulationActivityWatchdog(
state,
{ phase: "validation", heartbeat: true },
90_000,
TEST_STALL_TIMEOUT_MS,
);
state = watched.state;
expect(watched.observation.status).toBe("progressing");
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
expect(watched.state.integratingPhaseActive).toBe(false);
watched = observeSimulationActivityWatchdog(
state,
{ phase: "compilation", heartbeat: true, activitySequence: 7 },
180_000,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("progressing");
expect(watched.observation.activityIdleMs).toBeNull();
expect(isSimulationSolverActivityPhase("compilation")).toBe(false);
});
test("进入 integrating 时重新建立计时基线", () => {
let state = createSimulationActivityWatchdog(0);
({ state } = observeSimulationActivityWatchdog(
state,
{ phase: "initialization", heartbeat: true },
120_000,
TEST_STALL_TIMEOUT_MS,
));
let watched = observeSimulationActivityWatchdog(
state,
{
phase: "integrating",
heartbeat: true,
activitySequence: 11,
acceptedStepSequence: 3,
acceptedTime: 0.048,
},
180_000,
TEST_STALL_TIMEOUT_MS,
);
state = watched.state;
expect(watched.observation.status).toBe("progressing");
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
expect(watched.observation.activityIdleMs).toBe(0);
expect(watched.state.integratingPhaseActive).toBe(true);
expect(isSimulationSolverActivityPhase("integrating")).toBe(true);
watched = observeSimulationActivityWatchdog(
state,
{
phase: "integrating",
heartbeat: true,
activitySequence: 11,
acceptedStepSequence: 3,
acceptedTime: 0.048,
},
181_000,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("stalled");
});
test("离开 integrating 后清除旧积分阶段的停滞状态", () => {
let state = createSimulationActivityWatchdog(0);
({ state } = observeSimulationActivityWatchdog(
state,
{
phase: "integrating",
heartbeat: true,
activitySequence: 5,
},
0,
TEST_STALL_TIMEOUT_MS,
));
({ state } = observeSimulationActivityWatchdog(
state,
{
phase: "integrating",
heartbeat: true,
activitySequence: 5,
},
TEST_STALL_TIMEOUT_MS,
TEST_STALL_TIMEOUT_MS,
));
const watched = observeSimulationActivityWatchdog(
state,
{ phase: "postprocessing", heartbeat: true },
60_000,
TEST_STALL_TIMEOUT_MS,
);
expect(watched.observation.status).toBe("progressing");
expect(watched.observation.acceptedProgressIdleMs).toBe(0);
expect(watched.state.integratingPhaseActive).toBe(false);
expect(watched.state.lastActivitySequence).toBeNull();
});
@@ -0,0 +1,578 @@
import { expect, test, type Page } from "@playwright/test";
import { createHash } from "node:crypto";
import { readFileSync, statSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
type CompactProgressEvent = {
receivedAtMs: number;
phase?: string;
heartbeat: boolean;
progress?: number;
simulatedTime?: number;
totalTime?: number;
activitySequence?: number;
activityKind?: string;
currentTrialTime?: number | null;
rhsCallCount?: number;
acceptedStepSequence?: number;
acceptedTime?: number | null;
};
type CompactResult = {
success?: boolean;
status?: string;
partial?: boolean;
simulatedUntil?: number;
requestedStopTime?: number;
sampleCount?: number;
timePointCount?: number;
finalTime?: number;
};
type BrowserStreamEvidence = {
lineCount: number;
progressEvents: CompactProgressEvent[];
result: CompactResult | null;
streamErrors: Array<{
receivedAtMs: number;
status?: number;
message?: string;
detail?: unknown;
}>;
parseErrors: string[];
};
const PROJECT_PATH = fileURLToPath(
new URL("../../../tests/data/test-mql-8.json", import.meta.url),
);
const EVIDENCE_PATH = fileURLToPath(
new URL(
"../../../tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-browser-live-activity-v1-0.2.json",
import.meta.url,
),
);
const EXPECTED_RUNTIME_ENVIRONMENT = {
SIMULATION_CAUSAL_EXECUTOR_V2: "1",
SIMULATION_CAUSAL_COORDINATE_KERNEL: "1",
SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS: "1",
SIMULATION_CAUSAL_DIRECT_EQUATION_READERS: "1",
SIMULATION_CAUSAL_FAST_PATH: "1",
SIMULATION_MECHANICAL_ATOL_MODE: "legacy",
SIMULATION_ODE_JACOBIAN_MODE: "scipy",
SIMULATIONAPP_PROPERTY_CACHE: "on",
} as const;
function extractSimulationAttribute(xml: string, name: string) {
const match = xml.match(new RegExp(`\\b${name}="([^"]+)"`));
return match?.[1] ?? null;
}
function buildActivitySummary(events: CompactProgressEvent[]) {
const heartbeats = events.filter((event) => event.heartbeat);
const activityHeartbeats = heartbeats.filter(
(event) => typeof event.activitySequence === "number",
);
const activitySequences = activityHeartbeats.map(
(event) => event.activitySequence as number,
);
const acceptedTransitions: CompactProgressEvent[] = [];
let lastAcceptedStepSequence: number | undefined;
for (const event of events) {
if (
event.phase !== "integrating" ||
typeof event.acceptedStepSequence !== "number"
) {
continue;
}
if (event.acceptedStepSequence === lastAcceptedStepSequence) {
continue;
}
lastAcceptedStepSequence = event.acceptedStepSequence;
acceptedTransitions.push(event);
}
let maximumAcceptedPlateau:
| {
durationMs: number;
fromAcceptedStepSequence?: number;
toAcceptedStepSequence?: number;
fromAcceptedTime?: number | null;
toAcceptedTime?: number | null;
heartbeatCount: number;
activitySequenceStart: number | null;
activitySequenceEnd: number | null;
activitySequenceAdvanced: boolean;
}
| undefined;
for (let index = 1; index < acceptedTransitions.length; index += 1) {
const previous = acceptedTransitions[index - 1];
const current = acceptedTransitions[index];
const plateauHeartbeats = activityHeartbeats.filter(
(event) =>
event.receivedAtMs >= previous.receivedAtMs &&
event.receivedAtMs <= current.receivedAtMs,
);
const plateauActivitySequences = plateauHeartbeats.map(
(event) => event.activitySequence as number,
);
const candidate = {
durationMs: current.receivedAtMs - previous.receivedAtMs,
fromAcceptedStepSequence: previous.acceptedStepSequence,
toAcceptedStepSequence: current.acceptedStepSequence,
fromAcceptedTime: previous.acceptedTime,
toAcceptedTime: current.acceptedTime,
heartbeatCount: plateauHeartbeats.length,
activitySequenceStart: plateauActivitySequences.at(0) ?? null,
activitySequenceEnd: plateauActivitySequences.at(-1) ?? null,
activitySequenceAdvanced:
plateauActivitySequences.length >= 2 &&
(plateauActivitySequences.at(-1) as number) >
(plateauActivitySequences.at(0) as number),
};
if (
!maximumAcceptedPlateau ||
candidate.durationMs > maximumAcceptedPlateau.durationMs
) {
maximumAcceptedPlateau = candidate;
}
}
const slowRegionEvents = events.filter((event) => {
const reportedTimes = [
event.acceptedTime,
event.currentTrialTime,
event.simulatedTime,
].filter((value): value is number => typeof value === "number");
return reportedTimes.some((value) => value >= 0.0475 && value <= 0.0505);
});
return {
progressEventCount: events.length,
heartbeatCount: heartbeats.length,
activityTelemetryHeartbeatCount: activityHeartbeats.length,
firstActivitySequence: activitySequences.at(0) ?? null,
lastActivitySequence: activitySequences.at(-1) ?? null,
activitySequenceAdvanced:
activitySequences.length >= 2 &&
(activitySequences.at(-1) as number) >
(activitySequences.at(0) as number),
acceptedTransitionCount: acceptedTransitions.length,
maximumAcceptedPlateau: maximumAcceptedPlateau ?? null,
slowRegionEvents,
};
}
async function readBrowserStreamEvidence(page: Page): Promise<BrowserStreamEvidence> {
return page.evaluate(() => {
const state = (
window as typeof window & { __mql8LiveStreamEvidence?: BrowserStreamEvidence }
).__mql8LiveStreamEvidence;
return (
state ?? {
lineCount: 0,
progressEvents: [],
result: null,
streamErrors: [],
parseErrors: ["Browser stream evidence was not initialized."],
}
);
});
}
function emptyBrowserStreamEvidence(): BrowserStreamEvidence {
return {
lineCount: 0,
progressEvents: [],
result: null,
streamErrors: [],
parseErrors: [],
};
}
function retainMoreCompleteEvidence(
retained: BrowserStreamEvidence,
candidate: BrowserStreamEvidence,
) {
if (
candidate.lineCount > retained.lineCount ||
(candidate.lineCount === retained.lineCount &&
candidate.result !== null &&
retained.result === null)
) {
return candidate;
}
return retained;
}
test("test-mql-8 在真实浏览器流式链路完成 0.2 s BDF 仿真", async ({
page,
}, testInfo) => {
const startedAt = Date.now();
const startedAtIso = new Date(startedAt).toISOString();
const cancellationRequests: string[] = [];
const browserConsoleErrors: string[] = [];
const pageErrors: string[] = [];
let simulationXml = "";
let simulationId: string | undefined;
let workflowCompleted = false;
let retainedStreamEvidence = emptyBrowserStreamEvidence();
let evidenceRetentionTimer: ReturnType<typeof setInterval> | undefined;
page.on("request", (request) => {
const url = request.url();
if (url.includes("/api/system-xml/simulate-stream")) {
simulationXml = request.postData() ?? "";
simulationId = request.headers()["x-simulation-id"];
}
if (/\/api\/system-xml\/simulations\/[^/]+\/cancel(?:\?|$)/.test(url)) {
cancellationRequests.push(url);
}
});
page.on("console", (message) => {
if (message.type() === "error") {
browserConsoleErrors.push(message.text());
}
});
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.addInitScript(() => {
window.localStorage.clear();
window.sessionStorage.clear();
window.location.hash = "#/modeling";
const evidence: BrowserStreamEvidence = {
lineCount: 0,
progressEvents: [],
result: null,
streamErrors: [],
parseErrors: [],
};
(
window as typeof window & { __mql8LiveStreamEvidence?: BrowserStreamEvidence }
).__mql8LiveStreamEvidence = evidence;
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args: Parameters<typeof window.fetch>) => {
const response = await nativeFetch(...args);
const input = args[0];
const requestUrl =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (!requestUrl.includes("/api/system-xml/simulate-stream") || !response.body) {
return response;
}
const decoder = new TextDecoder();
let buffer = "";
const recordLine = (line: string) => {
const trimmed = line.trim();
if (!trimmed) {
return;
}
evidence.lineCount += 1;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const receivedAtMs = performance.now();
if (parsed.event === "progress") {
evidence.progressEvents.push({
receivedAtMs,
phase: typeof parsed.phase === "string" ? parsed.phase : undefined,
heartbeat: parsed.heartbeat === true,
progress:
typeof parsed.progress === "number" ? parsed.progress : undefined,
simulatedTime:
typeof parsed.simulatedTime === "number"
? parsed.simulatedTime
: undefined,
totalTime:
typeof parsed.totalTime === "number" ? parsed.totalTime : undefined,
activitySequence:
typeof parsed.activitySequence === "number"
? parsed.activitySequence
: undefined,
activityKind:
typeof parsed.activityKind === "string"
? parsed.activityKind
: undefined,
currentTrialTime:
typeof parsed.currentTrialTime === "number" ||
parsed.currentTrialTime === null
? parsed.currentTrialTime
: undefined,
rhsCallCount:
typeof parsed.rhsCallCount === "number"
? parsed.rhsCallCount
: undefined,
acceptedStepSequence:
typeof parsed.acceptedStepSequence === "number"
? parsed.acceptedStepSequence
: undefined,
acceptedTime:
typeof parsed.acceptedTime === "number" || parsed.acceptedTime === null
? parsed.acceptedTime
: undefined,
});
} else if (parsed.event === "result") {
const result = parsed.result as Record<string, unknown> | undefined;
const diagnostics = result?.diagnostics as
| Record<string, unknown>
| undefined;
const series = result?.series as Record<string, unknown> | undefined;
const times = Array.isArray(series?.time)
? (series.time as unknown[]).filter(
(value): value is number => typeof value === "number",
)
: [];
evidence.result = {
success:
typeof result?.success === "boolean" ? result.success : undefined,
status: typeof result?.status === "string" ? result.status : undefined,
partial:
typeof result?.partial === "boolean" ? result.partial : undefined,
simulatedUntil:
typeof result?.simulatedUntil === "number"
? result.simulatedUntil
: undefined,
requestedStopTime:
typeof result?.requestedStopTime === "number"
? result.requestedStopTime
: undefined,
sampleCount:
typeof diagnostics?.sampleCount === "number"
? diagnostics.sampleCount
: undefined,
timePointCount: times.length,
finalTime: times.at(-1),
};
} else if (parsed.event === "error") {
evidence.streamErrors.push({
receivedAtMs,
status: typeof parsed.status === "number" ? parsed.status : undefined,
message:
typeof parsed.message === "string" ? parsed.message : undefined,
detail: parsed.detail,
});
}
} catch (error) {
evidence.parseErrors.push(
error instanceof Error ? error.message : String(error),
);
}
};
const flushLines = () => {
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
recordLine(buffer.slice(0, newlineIndex));
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
}
};
const monitoredBody = response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
flushLines();
controller.enqueue(chunk);
},
flush() {
buffer += decoder.decode();
flushLines();
recordLine(buffer);
buffer = "";
},
}),
);
return new Response(monitoredBody, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
};
});
const retainCurrentStreamEvidence = async () => {
const candidate = await readBrowserStreamEvidence(page);
retainedStreamEvidence = retainMoreCompleteEvidence(
retainedStreamEvidence,
candidate,
);
};
evidenceRetentionTimer = setInterval(() => {
void retainCurrentStreamEvidence().catch(() => undefined);
}, 1_000);
try {
await page.goto("/");
await expect(page.locator(".catalog-source.ready")).toBeVisible({
timeout: 30_000,
});
await page.locator('input[type="file"]').setInputFiles(PROJECT_PATH);
const consolePanel = page.getByRole("complementary", {
name: "仿真控制台",
exact: true,
});
const consoleSummary = consolePanel.locator(
".simulation-console-dock-summary",
);
await expect(consoleSummary).toContainText(
"已导入工程:test-mql-8.json",
{ timeout: 30_000 },
);
const stopTimeInput = page.getByLabel("结束时间", { exact: true });
await stopTimeInput.fill("0.2");
await stopTimeInput.press("Enter");
await expect(stopTimeInput).toHaveValue("0.2");
await expect(page.getByLabel("采样步长", { exact: true })).toHaveValue(
"0.01",
);
await expect(
page.getByLabel("最大积分步长", { exact: true }),
).toHaveValue("0.001");
await expect(page.getByLabel("求解器", { exact: true })).toHaveValue("BDF");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect.poll(() => simulationXml.length, { timeout: 60_000 }).toBeGreaterThan(0);
expect(extractSimulationAttribute(simulationXml, "tStop")).toBe("0.2");
expect(extractSimulationAttribute(simulationXml, "sampleStep")).toBe("0.01");
expect(extractSimulationAttribute(simulationXml, "maxStep")).toBe("0.001");
expect(extractSimulationAttribute(simulationXml, "method")).toBe("BDF");
await expect(consoleSummary).toContainText(
"仿真完成,已生成新的结果,共 21 个采样点",
{ timeout: 480_000 },
);
await expect
.poll(async () => (await readBrowserStreamEvidence(page)).result?.status, {
timeout: 30_000,
})
.toBe("completed");
const streamEvidence = await readBrowserStreamEvidence(page);
expect(streamEvidence.parseErrors).toEqual([]);
expect(streamEvidence.streamErrors).toEqual([]);
expect(streamEvidence.result).toMatchObject({
success: true,
status: "completed",
partial: false,
simulatedUntil: 0.2,
requestedStopTime: 0.2,
sampleCount: 21,
timePointCount: 21,
finalTime: 0.2,
});
const activitySummary = buildActivitySummary(streamEvidence.progressEvents);
expect(activitySummary.heartbeatCount).toBeGreaterThan(0);
expect(activitySummary.activityTelemetryHeartbeatCount).toBeGreaterThan(1);
expect(activitySummary.activitySequenceAdvanced).toBe(true);
expect(cancellationRequests).toEqual([]);
expect(pageErrors).toEqual([]);
await page.getByRole("button", { name: "展开仿真控制台" }).click();
const consoleLog = consolePanel.getByRole("region", { name: "控制台日志" });
await expect(consoleLog).toContainText(
"仿真完成,已生成新的结果,共 21 个采样点",
);
await expect(consoleLog).not.toContainText("仿真失败");
await expect(consoleLog).not.toContainText("仿真因进度异常而终止");
if (
activitySummary.maximumAcceptedPlateau &&
activitySummary.maximumAcceptedPlateau.durationMs >= 60_000
) {
expect(
activitySummary.maximumAcceptedPlateau.activitySequenceAdvanced,
).toBe(true);
await expect(consoleLog).toContainText("后端内部活动仍在推进");
}
workflowCompleted = true;
} finally {
if (evidenceRetentionTimer !== undefined) {
clearInterval(evidenceRetentionTimer);
}
await retainCurrentStreamEvidence().catch(() => undefined);
const streamEvidence = retainedStreamEvidence;
const activitySummary = buildActivitySummary(streamEvidence.progressEvents);
const finishedAt = Date.now();
const projectBytes = readFileSync(PROJECT_PATH);
const evidence = {
schemaVersion: 1,
kind: "live-browser-stream-verification",
generatedAt: new Date(finishedAt).toISOString(),
startedAt: startedAtIso,
wallSeconds: (finishedAt - startedAt) / 1_000,
workflowCompleted,
topology: {
browserOrigin: "http://127.0.0.1:14173",
backendOrigin: "http://127.0.0.1:18181",
servingMode: "isolated-vite-dev-proxy",
},
source: {
path: "tests/data/test-mql-8.json",
bytes: statSync(PROJECT_PATH).size,
sha256: createHash("sha256").update(projectBytes).digest("hex"),
},
target: {
tStart: 0,
tStop: 0.2,
sampleStep: 0.01,
maxStep: 0.001,
method: "BDF",
},
runtimeEnvironment: EXPECTED_RUNTIME_ENVIRONMENT,
request: {
simulationId: simulationId ?? null,
xmlBytes: Buffer.byteLength(simulationXml),
simulationAttributes: {
tStart: extractSimulationAttribute(simulationXml, "tStart"),
tStop: extractSimulationAttribute(simulationXml, "tStop"),
sampleStep: extractSimulationAttribute(simulationXml, "sampleStep"),
maxStep: extractSimulationAttribute(simulationXml, "maxStep"),
method: extractSimulationAttribute(simulationXml, "method"),
},
},
ndjson: {
lineCount: streamEvidence.lineCount,
parseErrors: streamEvidence.parseErrors,
streamErrors: streamEvidence.streamErrors,
result: streamEvidence.result,
activity: activitySummary,
},
browser: {
cancellationRequests,
pageErrors,
consoleErrors: browserConsoleErrors,
},
acceptance: {
completedWith21Samples:
workflowCompleted &&
streamEvidence.result?.status === "completed" &&
streamEvidence.result?.sampleCount === 21 &&
streamEvidence.result?.timePointCount === 21,
noCancellationRequest: cancellationRequests.length === 0,
noStreamError: streamEvidence.streamErrors.length === 0,
noPageError: pageErrors.length === 0,
activityTelemetryAdvanced: activitySummary.activitySequenceAdvanced,
},
};
const serializedEvidence = `${JSON.stringify(evidence, null, 2)}\n`;
writeFileSync(EVIDENCE_PATH, serializedEvidence, "utf8");
await testInfo.attach("mql8-live-browser-evidence", {
body: Buffer.from(serializedEvidence),
contentType: "application/json",
});
const screenshot = await page.screenshot({ fullPage: true }).catch(() => null);
if (screenshot) {
await testInfo.attach("mql8-live-browser-final-page", {
body: screenshot,
contentType: "image/png",
});
}
}
});
@@ -1,8 +1,11 @@
import { expect, test } from "@playwright/test";
import {
SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS,
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES,
SIMULATION_SOLVER_STALL_TIMEOUT_MS,
solverActivityStallTimeoutMessage,
solverStallTimeoutMessage,
solverStallTimeoutReached,
} from "../../src/simulationTimeout";
@@ -19,3 +22,11 @@ test("solver stall messages use the configured duration", () => {
expect(solverStallTimeoutMessage("recovering")).toContain("连续 15 分钟");
expect(solverStallTimeoutMessage("recovering")).toContain("恢复部分结果");
});
test("activity-aware watchdog keeps its shorter true-stall threshold", () => {
expect(SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS).toBe(60);
expect(SIMULATION_ACTIVITY_STALL_TIMEOUT_MS).toBe(60_000);
expect(solverActivityStallTimeoutMessage("detected")).toContain(
"接受步和内部活动均连续 60 秒",
);
});
@@ -257,7 +257,10 @@
"environment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
@@ -0,0 +1,86 @@
{
"schemaVersion": 1,
"generatedAt": "2026-08-18T19:06:44Z",
"purpose": "Compact evidence for the real streaming API path and activity-aware browser watchdog.",
"source": {
"xmlPath": "tests/data/test-mql-8.xml",
"xmlSha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
"jsonPath": "tests/data/test-mql-8.json",
"jsonSha256": "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18"
},
"request": {
"endpoint": "/api/system-xml/simulate-stream",
"simulationId": "diag-opt00-api-optimized-20260818",
"method": "BDF",
"tStart": 0.0,
"tStop": 0.2,
"sampleStep": 0.01,
"maxStep": 0.001
},
"response": {
"httpStatus": 200,
"contentType": "application/x-ndjson",
"phase": "completed",
"success": true,
"simulatedUntil": 0.2,
"sampleCount": 21,
"strictlyIncreasingTimes": true,
"allNumericSeriesFinite": true,
"progressEventCount": 255,
"resultEventCount": 1,
"integrationSeconds": 146.675315209,
"totalSeconds": 147.298753857,
"traceFirstProgressToResultSeconds": 147.481,
"integrationTotals": {
"nfev": 6318,
"njev": 331,
"nlu": 1060,
"acceptedStepCount": 2113,
"solverStartCount": 2,
"stateTransitionCount": 0,
"recoverableRetryCount": 0
}
},
"activityWindow": {
"maximumNonHeartbeatProgressGapSeconds": 57.185072,
"start": {
"wallTimeUtc": "18:46:24.877853",
"simulatedTime": 0.048633224495,
"activitySequence": 32081,
"acceptedStepSequence": 358,
"rhsCallCount": 7840
},
"end": {
"wallTimeUtc": "18:47:22.062925",
"simulatedTime": 0.049248430694,
"activitySequence": 68103,
"acceptedStepSequence": 1743,
"rhsCallCount": 16153
},
"interveningHeartbeatCount": 11,
"maximumInterEventGapSeconds": 5.074026,
"heartbeatActivityAdvanced": true,
"acceptedTimeAdvancedInsideThrottledProgressWindow": true,
"oldFrontendWouldUseAcceptedProgressOnly": true,
"activityAwareFrontendWouldCancel": false
},
"workerComparison": {
"reportPath": "tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-slow-region-exact-v1-0.2.json",
"reportSha256": "34268e5864f33709018d7cbe631654d2a835b10dd252886509244f913ae18559",
"reportBytes": 437852,
"finalValuesEqual": true,
"integrationCountersEqual": true,
"numericalDiagnosticsEqual": true
},
"rawCapture": {
"retainedOutsideRepository": true,
"ndjsonSha256": "52bcca58147e1c78e5137d7134f79d21b314092156e0de0e9c3ebb58b40fd616",
"traceSha256": "8a26a349b40556811f9a0f151ebe801d6a126c782c030fe55cfb23c9630a8a90",
"headersSha256": "b5baf9b3d5ef1f2c18a9fb4894e78be6422b9e7c8f8821f2e586e92d21348a71"
},
"limitations": [
"The literal interactive browser click path was not captured in this artifact; the same watchdog state machine is covered by Playwright.",
"SciPy-internal finite-difference Jacobian builds are reflected by njev but are not classified by the realtime jacobianEvaluationCount field.",
"Cooperative cancellation cannot hard-kill a native or Python call that never returns."
]
}
@@ -0,0 +1,321 @@
{
"schemaVersion": 1,
"kind": "live-browser-stream-verification",
"generatedAt": "2026-08-19T15:12:44.536Z",
"startedAt": "2026-08-19T15:10:08.400Z",
"wallSeconds": 156.136,
"workflowCompleted": true,
"topology": {
"browserOrigin": "http://127.0.0.1:14173",
"backendOrigin": "http://127.0.0.1:18181",
"servingMode": "isolated-vite-dev-proxy"
},
"source": {
"path": "tests/data/test-mql-8.json",
"bytes": 272592,
"sha256": "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18"
},
"target": {
"tStart": 0,
"tStop": 0.2,
"sampleStep": 0.01,
"maxStep": 0.001,
"method": "BDF"
},
"runtimeEnvironment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
"request": {
"simulationId": "e694d041-129d-4b49-af9e-747d85f0056d",
"xmlBytes": 104110,
"simulationAttributes": {
"tStart": "0",
"tStop": "0.2",
"sampleStep": "0.01",
"maxStep": "0.001",
"method": "BDF"
}
},
"ndjson": {
"lineCount": 256,
"parseErrors": [],
"streamErrors": [],
"result": {
"success": true,
"status": "completed",
"partial": false,
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
"sampleCount": 21,
"timePointCount": 21,
"finalTime": 0.2
},
"activity": {
"progressEventCount": 255,
"heartbeatCount": 13,
"activityTelemetryHeartbeatCount": 13,
"firstActivitySequence": 25114,
"lastActivitySequence": 66670,
"activitySequenceAdvanced": true,
"acceptedTransitionCount": 249,
"maximumAcceptedPlateau": {
"durationMs": 5050.799999237061,
"fromAcceptedStepSequence": 1268,
"toAcceptedStepSequence": 1404,
"fromAcceptedTime": 0.04886080559693986,
"toAcceptedTime": 0.048885635713233705,
"heartbeatCount": 2,
"activitySequenceStart": 57190,
"activitySequenceEnd": 60152,
"activitySequenceAdvanced": true
},
"slowRegionEvents": [
{
"receivedAtMs": 43039.39999961853,
"phase": "integrating",
"heartbeat": false,
"progress": 24,
"simulatedTime": 0.04807049151065195,
"totalTime": 0.2,
"activitySequence": 32061,
"activityKind": "accepted_step",
"currentTrialTime": 0.04807049151065195,
"rhsCallCount": 7836,
"acceptedStepSequence": 356,
"acceptedTime": 0.04807049151065195
},
{
"receivedAtMs": 43131.29999923706,
"phase": "integrating",
"heartbeat": false,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 32081,
"activityKind": "accepted_step",
"currentTrialTime": 0.0486332244950324,
"rhsCallCount": 7840,
"acceptedStepSequence": 358,
"acceptedTime": 0.0486332244950324
},
{
"receivedAtMs": 48131,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 35600,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.0486991505296288,
"rhsCallCount": 8689,
"acceptedStepSequence": 420,
"acceptedTime": 0.048699149085530725
},
{
"receivedAtMs": 53157.89999961853,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 38734,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04870448422554062,
"rhsCallCount": 9413,
"acceptedStepSequence": 539,
"acceptedTime": 0.04870448402151976
},
{
"receivedAtMs": 58201.89999961853,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 41712,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04872888403105117,
"rhsCallCount": 10076,
"acceptedStepSequence": 702,
"acceptedTime": 0.048728801929735416
},
{
"receivedAtMs": 63233.59999847412,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 44741,
"activityKind": "state_derivatives",
"currentTrialTime": 0.04872901764870005,
"rhsCallCount": 10765,
"acceptedStepSequence": 838,
"acceptedTime": 0.04872901502576142
},
{
"receivedAtMs": 68278.29999923706,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 47988,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04880991845348599,
"rhsCallCount": 11549,
"acceptedStepSequence": 894,
"acceptedTime": 0.04880991659730141
},
{
"receivedAtMs": 73322.5,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 50944,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04885091695689501,
"rhsCallCount": 12205,
"acceptedStepSequence": 1060,
"acceptedTime": 0.048849397131310805
},
{
"receivedAtMs": 78344,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 53758,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04885415602948481,
"rhsCallCount": 12830,
"acceptedStepSequence": 1217,
"acceptedTime": 0.048852938260664384
},
{
"receivedAtMs": 83372,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 57190,
"activityKind": "solver_step",
"currentTrialTime": 0.048860806745699516,
"rhsCallCount": 13662,
"acceptedStepSequence": 1268,
"acceptedTime": 0.04886080559693986
},
{
"receivedAtMs": 88422.79999923706,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 60152,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04888569214870155,
"rhsCallCount": 14335,
"acceptedStepSequence": 1404,
"acceptedTime": 0.048885635713233705
},
{
"receivedAtMs": 93441.29999923706,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 63490,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04888577358403684,
"rhsCallCount": 15121,
"acceptedStepSequence": 1501,
"acceptedTime": 0.048885772226330206
},
{
"receivedAtMs": 98461.89999961853,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 66670,
"activityKind": "solver_step",
"currentTrialTime": 0.04888814764518708,
"rhsCallCount": 15855,
"acceptedStepSequence": 1622,
"acceptedTime": 0.048888146224637836
},
{
"receivedAtMs": 101649.59999847412,
"phase": "integrating",
"heartbeat": false,
"progress": 25,
"simulatedTime": 0.049248430694378086,
"totalTime": 0.2,
"activitySequence": 68103,
"activityKind": "accepted_step",
"currentTrialTime": 0.049248430694378086,
"rhsCallCount": 16153,
"acceptedStepSequence": 1743,
"acceptedTime": 0.049248430694378086
},
{
"receivedAtMs": 101755.69999885559,
"phase": "integrating",
"heartbeat": false,
"progress": 25,
"simulatedTime": 0.049780741149075,
"totalTime": 0.2,
"activitySequence": 68133,
"activityKind": "accepted_step",
"currentTrialTime": 0.049780741149075,
"rhsCallCount": 16159,
"acceptedStepSequence": 1746,
"acceptedTime": 0.049780741149075
},
{
"receivedAtMs": 102123.79999923706,
"phase": "integrating",
"heartbeat": false,
"progress": 25,
"simulatedTime": 0.05031324569277993,
"totalTime": 0.2,
"activitySequence": 68377,
"activityKind": "accepted_step",
"currentTrialTime": 0.05031324569277993,
"rhsCallCount": 16219,
"acceptedStepSequence": 1748,
"acceptedTime": 0.05031324569277993
}
]
}
},
"browser": {
"cancellationRequests": [],
"pageErrors": [],
"consoleErrors": [
"Failed to load resource: the server responded with a status of 404 (Not Found)"
]
},
"acceptance": {
"completedWith21Samples": true,
"noCancellationRequest": true,
"noStreamError": true,
"noPageError": true,
"activityTelemetryAdvanced": true
}
}
@@ -129,7 +129,10 @@
"environment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
+135
View File
@@ -10,6 +10,7 @@ from app.main import (
compile_system_xml_network,
run_system_xml_simulation,
)
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port
@@ -302,6 +303,140 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
self.assertEqual(tolerances, [1.0e-12, 1.0e-12])
self.assertEqual(len(tolerances), len(system.initial_state_vector()))
def test_self_loop_contact_does_not_relax_mass_velocity(self) -> None:
contact_parameters = {**LSTP00A_DEFAULTS, "rcont": 1.0e11}
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-self-contact",
nodes=[
component_node(
"mass_1",
"amesim_mecmas21",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
MECMAS21_DEFAULTS,
),
component_node(
"contact_1",
"amesim_lstp00a",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
contact_parameters,
),
],
edges=[
physical_edge(
"edge-1",
"mass_1",
"port_1",
"contact_1",
"port_1",
),
physical_edge(
"edge-2",
"mass_1",
"port_2",
"contact_1",
"port_2",
),
],
simulation={
"t_start": 0.0,
"t_stop": 0.02,
"step": 0.01,
"max_step": 0.005,
"method": "BDF",
},
)
system = GenericFluidSystem(compile_reactflow_network(project))
plan = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
self.assertEqual(plan.values, (1.0e-12, 1.0e-12))
self.assertEqual(plan.groups[0].contacts, ())
self.assertFalse(plan.groups[0].eligible)
self.assertEqual(plan.groups[0].reason, "noFlexibleContact")
def test_weak_contact_damping_does_not_relax_velocity(self) -> None:
project = elastic_contact_project()
project.nodes[2].data.parameters["rcont"] = 1.0e-300
system = GenericFluidSystem(compile_reactflow_network(project))
plan = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
self.assertTrue(plan.groups)
self.assertTrue(all(not group.eligible for group in plan.groups))
self.assertTrue(
all(group.reason == "weakContactDamping" for group in plan.groups)
)
self.assertTrue(
all(
group.velocity_atol == 1.0e-12
and group.position_atol == 1.0e-12
for group in plan.groups
)
)
def test_legacy_tolerance_ignores_extreme_contact_scales(self) -> None:
project = elastic_contact_project()
project.nodes[2].data.parameters.update(
{"kcont": 5.0e-324, "Pdis": 5.0e-324, "rcont": 1.0}
)
system = GenericFluidSystem(compile_reactflow_network(project))
legacy = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="legacy",
)
contact_aware = (
system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
)
self.assertTrue(all(value == 1.0e-12 for value in legacy.values))
self.assertTrue(
all(group.reason == "legacyMode" for group in legacy.groups)
)
self.assertTrue(
all(
group.reason == "invalidContactScale"
for group in contact_aware.groups
)
)
def test_contact_aware_tolerance_is_limited_to_bdf(self) -> None:
system = GenericFluidSystem(
compile_reactflow_network(zero_force_mass_project())
)
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.001,
method="RK45",
max_step=0.001,
),
sample_step=0.001,
)
self.assertTrue(result.success)
tolerance = result.diagnostics["integration"][
"mechanicalAbsoluteTolerance"
]
self.assertEqual(tolerance["mode"], "legacy")
def test_sparse_reactflow_mecmas_parameters_use_registered_defaults(self) -> None:
project = mecmas_project(1.0, 1.0)
+201 -2
View File
@@ -1,13 +1,42 @@
from __future__ import annotations
import unittest
from math import sqrt
from math import log10, sqrt
from unittest.mock import patch
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl0001,
AmesimPnl0002,
AmesimPnl00r,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
class _ScaledReynoldsPnl0001(AmesimPnl0001):
def __init__(self, *args, reynolds_scale: float, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.reynolds_scale = float(reynolds_scale)
self.reynolds_calls: list[tuple[float, float]] = []
def reynolds_number(self, mass_flow: float, temperature: float) -> float:
self.reynolds_calls.append((mass_flow, temperature))
return self.reynolds_scale * super().reynolds_number(
mass_flow,
temperature,
)
class _DelegatingFrictionPnl0001(AmesimPnl0001):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.friction_reynolds: list[float] = []
def friction_factor(self, reynolds_number: float) -> float:
self.friction_reynolds.append(reynolds_number)
return super().friction_factor(reynolds_number)
class AmesimPnl0001ComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
@@ -103,6 +132,176 @@ class AmesimPnl0001ComponentTests(unittest.TestCase):
self.assertEqual(pipe.mass_flow(15.3e6, 15.3e6 + 2.0e-9, 293.15), 0.0)
def test_overridden_reynolds_number_is_called_on_every_iteration(self) -> None:
slow_reynolds = _ScaledReynoldsPnl0001(
"slow_reynolds",
self.medium,
reynolds_scale=0.25,
)
fast_reynolds = _ScaledReynoldsPnl0001(
"fast_reynolds",
self.medium,
reynolds_scale=4.0,
)
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": 1.0e6,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
slow_flow = slow_reynolds._one_way_pn2pipefr_mass_flow(**options)
fast_flow = fast_reynolds._one_way_pn2pipefr_mass_flow(**options)
self.assertEqual(len(slow_reynolds.reynolds_calls), 4)
self.assertEqual(len(fast_reynolds.reynolds_calls), 4)
self.assertTrue(
all(
temperature == 300.0
for _mass_flow, temperature in slow_reynolds.reynolds_calls
)
)
self.assertLess(slow_flow, fast_flow)
def test_zero_iterations_does_not_evaluate_dynamic_viscosity(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
original = IdealGasMedium.dynamic_viscosity
with (
patch.object(
IdealGasMedium,
"dynamic_viscosity",
autospec=True,
side_effect=original,
) as dynamic_viscosity,
patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm,
):
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=0,
)
self.assertGreater(flow, 0.0)
dynamic_viscosity.assert_not_called()
logarithm.assert_not_called()
def test_builtin_iterations_evaluate_dynamic_viscosity_once(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
original = IdealGasMedium.dynamic_viscosity
with patch.object(
IdealGasMedium,
"dynamic_viscosity",
autospec=True,
side_effect=original,
) as dynamic_viscosity:
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=4,
)
self.assertGreater(flow, 0.0)
dynamic_viscosity.assert_called_once_with(self.medium, 300.0)
def test_builtin_fixed_point_evaluates_fully_rough_term_once(self) -> None:
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": 1.0e6,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
pipes = (
AmesimPnl00r("pnl_00r", self.medium, rr=1.0e-5),
AmesimPnl0001("pnl_0001", self.medium, rr=1.0e-5),
AmesimPnl0002("pnl_0002", self.medium, rr=1.0e-5),
)
for pipe in pipes:
with self.subTest(model=type(pipe).__name__):
with patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm:
flow = AmesimPnl0001._one_way_pn2pipefr_mass_flow(
pipe,
**options,
)
self.assertGreater(flow, 0.0)
fully_rough_argument = pipe.rr / 3.7
self.assertEqual(
sum(
call.args == (fully_rough_argument,)
for call in logarithm.call_args_list
),
1,
)
self.assertEqual(logarithm.call_count, 5)
def test_overridden_friction_factor_keeps_virtual_iteration_path(self) -> None:
pipe = _DelegatingFrictionPnl0001(
"custom_friction",
self.medium,
rr=1.0e-5,
)
with patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm:
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=4,
)
self.assertGreater(flow, 0.0)
self.assertEqual(len(pipe.friction_reynolds), 4)
self.assertEqual(logarithm.call_count, 8)
def test_precomputed_fully_rough_path_is_bitwise_identical_by_regime(
self,
) -> None:
for index, downstream_pressure in enumerate(
(1_999_999.999, 1_999_990.0, 1_999_900.0, 1.0e6)
):
with self.subTest(downstream_pressure=downstream_pressure):
built_in = AmesimPnl0001(
f"built_in_{index}",
self.medium,
rr=1.0e-5,
)
virtual = _DelegatingFrictionPnl0001(
f"virtual_{index}",
self.medium,
rr=1.0e-5,
)
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": downstream_pressure,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
actual = built_in._one_way_pn2pipefr_mass_flow(**options)
expected = virtual._one_way_pn2pipefr_mass_flow(**options)
self.assertEqual(actual.hex(), expected.hex())
def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
pipe.port_1.p = 101000.0
+3
View File
@@ -672,7 +672,10 @@ class ProgressiveSuiteTests(unittest.TestCase):
[{
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on",
}],
+133 -3
View File
@@ -12,6 +12,7 @@ from app.simulation.solvers.causal_ir import (
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_pnl0001_xml import amesim_pnl0001_project
from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
from tests.test_generic_system_xml_simulation import chain_project
@@ -19,11 +20,102 @@ from tests.test_generic_system_xml_simulation import chain_project
TARGET_XML = Path("tests/data/test-mql-8.xml")
class _SyntheticDirectReaderError(Exception):
pass
def _system(project) -> GenericFluidSystem:
return GenericFluidSystem(compile_reactflow_network(project))
class CausalNumericIRTests(unittest.TestCase):
def _assert_direct_reader_exception_is_controlled(
self,
exception: Exception,
) -> None:
system = _system(amesim_pnl0001_project())
solver = system.pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
ir = compilation.ir
direct_evaluation = next(
evaluation
for stage in ir.program.effort_stages
for evaluation in stage.evaluations
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
)
evaluators = list(ir.bindings.evaluators)
victim = solver.unknowns[-1]
before = tuple(unknown.read() for unknown in solver.unknowns)
def failing_reader() -> float:
victim.write(victim.read() + 123.0)
raise exception
evaluators[direct_evaluation.evaluator_slot] = failing_reader
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,
f"effortEvaluationFailed:{type(exception).__name__}",
)
self.assertTrue(result.rolled_back)
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
before,
)
def test_direct_reader_key_error_returns_controlled_failure(self) -> None:
self._assert_direct_reader_exception_is_controlled(
KeyError("synthetic direct-reader key failure")
)
def test_direct_reader_custom_exception_returns_controlled_failure(
self,
) -> None:
self._assert_direct_reader_exception_is_controlled(
_SyntheticDirectReaderError("synthetic direct-reader failure")
)
def test_direct_reader_memory_error_remains_fatal(self) -> None:
system = _system(amesim_pnl0001_project())
compilation = compile_causal_numeric_ir(system.pressure_flow_solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
ir = compilation.ir
direct_evaluation = next(
evaluation
for stage in ir.program.effort_stages
for evaluation in stage.evaluations
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
)
evaluators = list(ir.bindings.evaluators)
def failing_reader() -> float:
raise MemoryError("synthetic direct-reader allocation failure")
evaluators[direct_evaluation.evaluator_slot] = failing_reader
failing_ir = replace(
ir,
bindings=replace(ir.bindings, evaluators=tuple(evaluators)),
)
with self.assertRaisesRegex(MemoryError, "allocation failure"):
failing_ir.execute(
failing_ir.create_workspace(),
transactional=True,
)
def test_structure_signature_is_stable_and_excludes_bindings(self) -> None:
first = compile_causal_numeric_ir(
_system(zero_force_mass_project()).pressure_flow_solver
@@ -242,18 +334,56 @@ class TargetCausalNumericIRStructureTests(unittest.TestCase):
self.assertEqual(len(pressure_stage.operations), 76)
self.assertEqual(len(pressure_stage.evaluations), 40)
self.assertTrue(
all(
self.assertEqual(
sum(
evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
for evaluation in pressure_stage.evaluations
),
20,
)
self.assertEqual(
sum(
evaluation.opcode
is CausalIROpcode.EFFORT_COMPONENT_RESIDUAL
for evaluation in pressure_stage.evaluations
)
),
20,
)
self.assertEqual(
self.system.pressure_flow_solver.causal_execution_diagnostics()[
"directEffortAnchorCount"
],
20,
)
self.assertEqual(
[len(stage.target_slots) for stage in program.flow_stages],
[114, 134, 49, 33, 5, 1],
)
def test_target_preserves_exact_direct_sum_assignment_counts(self) -> None:
solver = self.system.pressure_flow_solver
self.assertEqual(
solver.causal_execution_diagnostics()[
"directSumFlowAssignmentCount"
],
38,
)
component_equation_ids = frozenset(
equation.id
for evaluation in solver._component_equation_plan
for equation in evaluation.templates
)
secondary = (
self.system._thermofluid_closure_plan.secondary_block_solvers[0]
)
secondary_direct_count = sum(
assignment.evaluate is not None
and assignment.equation_id in component_equation_ids
for stage in secondary._selected_explicit_flow_plan
for assignment in stage.assignments
)
self.assertEqual(secondary_direct_count, 36)
def test_target_reference_execution_matches_all_compatibility_slots(self) -> None:
assert self.compilation.ir is not None
solver = self.system.pressure_flow_solver
+160
View File
@@ -1,5 +1,6 @@
import math
import sys
import threading
import types
import unittest
from unittest.mock import patch
@@ -8,6 +9,7 @@ from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.solvers.solver import (
IntegrationCancelled,
SolveIVPConfig,
SolverActivityTracker,
StateTransition,
integrate_ode,
)
@@ -44,6 +46,164 @@ def _counting_fixed_step_solver(
class IntegrateOdeTests(unittest.TestCase):
def test_activity_tracking_preserves_the_exact_trajectory(self) -> None:
options = {
"rhs": lambda time, state: [
-0.5 * float(state[0]) + 0.1 * float(time)
],
"initial_state": [1.0],
"config": SolveIVPConfig(
t_start=0.0,
t_stop=0.1,
method="BDF",
max_step=0.01,
),
"t_eval": [0.0, 0.025, 0.05, 0.075, 0.1],
"cancel_check": lambda: False,
}
baseline = integrate_ode(**options)
tracked = integrate_ode(
**options,
activity_tracker=SolverActivityTracker(),
)
self.assertTrue(baseline.success, baseline.message)
self.assertTrue(tracked.success, tracked.message)
self.assertEqual(tracked.t, baseline.t)
self.assertEqual(tracked.y, baseline.y)
self.assertEqual(tracked.solver_segments, baseline.solver_segments)
def test_activity_advances_while_accepted_time_is_unchanged(self) -> None:
import numpy as np
rhs_started = threading.Event()
release_rhs = threading.Event()
tracker = SolverActivityTracker()
results = []
errors: list[BaseException] = []
class BlockingSolver:
def __init__(self, fun, t0, y0, t_bound, **_kwargs):
self.fun = fun
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.status = "running"
self.nfev = 0
self.njev = 0
self.nlu = 0
def step(self):
self.fun(0.25, self.y)
self.nfev += 1
self.t = self.t_bound
self.y = np.asarray([self.t], dtype=float)
self.status = "finished"
return None
def rhs(_time, _state):
rhs_started.set()
if not release_rhs.wait(timeout=2.0):
raise TimeoutError("test did not release the blocking RHS")
return [1.0]
def run_integration() -> None:
try:
results.append(
integrate_ode(
rhs=rhs,
initial_state=[0.0],
config=SolveIVPConfig(t_stop=1.0, method="BDF"),
cancel_check=lambda: False,
activity_tracker=tracker,
)
)
except BaseException as exc: # pragma: no cover - test handoff
errors.append(exc)
with patch("scipy.integrate.BDF", BlockingSolver):
worker = threading.Thread(target=run_integration)
worker.start()
self.assertTrue(rhs_started.wait(timeout=2.0))
during_rhs = tracker.snapshot()
self.assertEqual(during_rhs.activity_kind, "rhs")
self.assertGreater(during_rhs.activity_sequence, 0)
self.assertEqual(during_rhs.current_trial_time, 0.25)
self.assertEqual(during_rhs.rhs_call_count, 1)
self.assertEqual(during_rhs.accepted_step_sequence, 0)
self.assertEqual(during_rhs.accepted_time, 0.0)
release_rhs.set()
worker.join(timeout=2.0)
self.assertFalse(worker.is_alive())
self.assertEqual(errors, [])
self.assertTrue(results[0].success, results[0].message)
completed = tracker.snapshot()
self.assertEqual(completed.accepted_step_sequence, 1)
self.assertEqual(completed.accepted_time, 1.0)
self.assertEqual(completed.solver_step_sequence, 1)
def test_activity_distinguishes_blocking_jacobian_evaluation(self) -> None:
import numpy as np
jacobian_started = threading.Event()
release_jacobian = threading.Event()
tracker = SolverActivityTracker()
results = []
class BlockingSolver:
def __init__(self, _fun, t0, y0, t_bound, **options):
self.jac = options["jac"]
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.status = "running"
self.nfev = 0
self.njev = 0
self.nlu = 0
def step(self):
self.jac(0.5, self.y)
self.njev += 1
self.t = self.t_bound
self.status = "finished"
return None
class BlockingJacobian:
def __call__(self, _time, _state):
jacobian_started.set()
if not release_jacobian.wait(timeout=2.0):
raise TimeoutError("test did not release the Jacobian")
return np.asarray([[0.0]])
def run_integration() -> None:
results.append(
integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[0.0],
config=SolveIVPConfig(t_stop=1.0, method="BDF"),
cancel_check=lambda: False,
jac=BlockingJacobian(),
activity_tracker=tracker,
)
)
with patch("scipy.integrate.BDF", BlockingSolver):
worker = threading.Thread(target=run_integration)
worker.start()
self.assertTrue(jacobian_started.wait(timeout=2.0))
during_jacobian = tracker.snapshot()
self.assertEqual(during_jacobian.activity_kind, "jacobian")
self.assertEqual(during_jacobian.jacobian_evaluation_count, 1)
self.assertEqual(during_jacobian.rhs_call_count, 0)
self.assertEqual(during_jacobian.accepted_step_sequence, 0)
release_jacobian.set()
worker.join(timeout=2.0)
self.assertFalse(worker.is_alive())
self.assertTrue(results[0].success, results[0].message)
def test_generic_solver_keeps_canonical_default_tolerance(self) -> None:
self.assertEqual(SolveIVPConfig().atol, 1.0e-8)
+83 -5
View File
@@ -26,7 +26,10 @@ from app.main import (
)
from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe
from app.simulation.registry import get_component_model_spec
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.solvers.solver import (
SolveIVPConfig,
SolverActivityTracker,
)
from app.simulation.systems.generic import (
GenericFluidSystem,
SimulationPreparationError,
@@ -259,7 +262,9 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
network = compile_reactflow_network(chain_project())
progress: list[tuple[float, str]] = []
result = GenericFluidSystem(network).simulate(
activity_tracker = SolverActivityTracker()
system = GenericFluidSystem(network)
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
@@ -268,6 +273,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
),
sample_step=0.005,
progress_callback=lambda value, phase: progress.append((value, phase)),
activity_tracker=activity_tracker,
)
self.assertTrue(result.success)
@@ -281,6 +287,23 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
)
self.assertIn("integrating", {phase for _, phase in progress})
self.assertIn("postprocessing", {phase for _, phase in progress})
activity = activity_tracker.snapshot()
self.assertEqual(activity.activity_kind, "complete")
self.assertGreater(activity.rhs_call_count, 0)
self.assertGreater(activity.accepted_step_sequence, 0)
self.assertEqual(activity.accepted_time, 0.01)
self.assertGreater(activity.thermofluid_closure_count, 0)
self.assertEqual(
result.diagnostics["activity"],
activity.as_dict(),
)
self.assertIsNone(system._activity_tracker)
self.assertEqual(
result.diagnostics["integration"][
"mechanicalAbsoluteTolerance"
]["mode"],
"legacy",
)
self.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
total_mass = [
@@ -315,6 +338,33 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
pressure_flow["optimizerEvaluationCount"],
)
def test_activity_tracker_scope_is_restored_after_simulation_error(self) -> None:
system = GenericFluidSystem(compile_reactflow_network(chain_project()))
previous_tracker = SolverActivityTracker()
active_tracker = SolverActivityTracker()
system._activity_tracker = previous_tracker
with (
patch.object(
system,
"_simulate",
side_effect=RuntimeError("synthetic simulation failure"),
),
self.assertRaisesRegex(RuntimeError, "synthetic simulation failure"),
):
system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
method="BDF",
max_step=0.001,
),
sample_step=0.005,
activity_tracker=active_tracker,
)
self.assertIs(system._activity_tracker, previous_tracker)
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
cancel_event = threading.Event()
@@ -569,10 +619,24 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertEqual(snapshot["result"]["status"], "stopped")
def test_streaming_endpoint_keeps_quiet_solver_connection_alive(self) -> None:
def delayed_simulation(_xml_bytes, progress_callback, _cancel_check=None):
def delayed_simulation(
_xml_bytes,
progress_callback,
_cancel_check=None,
activity_tracker=None,
):
progress_callback(49, "integrating", "正在进行时间积分与压力流量求解")
time.sleep(0.03)
return {"success": True}
self.assertIsNotNone(activity_tracker)
activity_tracker.start_integration(0.0)
for trial_time in (0.0487, 0.0488, 0.0489, 0.0490):
activity_tracker.record_rhs(trial_time)
time.sleep(0.008)
return {
"success": True,
"status": "completed",
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
}
with (
patch("app.main.SIMULATION_STREAM_HEARTBEAT_SECONDS", 0.005),
@@ -590,7 +654,21 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertGreaterEqual(len(heartbeats), 1)
self.assertTrue(all(event["progress"] == 49 for event in heartbeats))
self.assertTrue(all(event["phase"] == "integrating" for event in heartbeats))
self.assertTrue(
all(event["acceptedStepSequence"] == 0 for event in heartbeats)
)
self.assertTrue(all(event["acceptedTime"] == 0.0 for event in heartbeats))
self.assertGreater(
len({event["activitySequence"] for event in heartbeats}),
1,
)
self.assertTrue(
all(event["activityKind"] == "rhs" for event in heartbeats)
)
self.assertEqual(events[-1]["event"], "result")
self.assertEqual(events[-1]["activityKind"], "complete")
self.assertEqual(events[-1]["acceptedStepSequence"], 0)
self.assertEqual(events[-1]["rhsCallCount"], 4)
def test_streaming_endpoint_returns_structured_validation_error(self) -> None:
events = [
+60 -2
View File
@@ -166,7 +166,7 @@ class MaxStepMatrixTests(unittest.TestCase):
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01, 0.05),
additional_checkpoint_times=(1.0885267285, 1.0997760276),
additional_checkpoint_times=(1.08, 1.10),
expected_projection_count=2,
case_executor=execute,
)
@@ -180,7 +180,7 @@ class MaxStepMatrixTests(unittest.TestCase):
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),
(0.0, 0.04, 0.8, 1.0, 1.08, 1.1, 2.0),
)
self.assertEqual(
report["configuration"]["horizons"][1]["horizonCaseId"],
@@ -248,6 +248,50 @@ class MaxStepMatrixTests(unittest.TestCase):
prefix_comparisons[0]["stateProjection"]["valueMismatchCount"], 0
)
def test_cross_horizon_comparison_excludes_short_terminal(self) -> None:
def execute(request: RegressionCaseRequest) -> dict[str, object]:
result = _fake_completed(request)
if request.stop_time <= 1.0:
checkpoints = result["worker"]["summary"]["physicalContract"][
"checkpoints"
]
for checkpoint in checkpoints:
if checkpoint["requestedTime"] == request.stop_time:
checkpoint["stateValues"]["state.a"] += 1.0
return result
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01,),
expected_projection_count=2,
case_executor=execute,
)
comparison = report["comparisons"]["sameMaxStepAcrossHorizons"][0]
self.assertTrue(comparison["strictPrefix"])
self.assertTrue(comparison["passed"])
self.assertNotIn(
1.0,
comparison["stateProjection"]["commonCheckpointTimes"],
)
def test_same_horizon_comparison_keeps_terminal_checkpoint(self) -> None:
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=_fake_completed,
)
comparison = report["comparisons"]["sameHorizonAcrossMaxSteps"][0]
self.assertFalse(comparison["strictPrefix"])
self.assertIn(
1.0,
comparison["stateProjection"]["commonCheckpointTimes"],
)
def test_horizons_must_increase_and_timeout_override_is_bounded(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError, "strictly increasing"
@@ -259,6 +303,20 @@ class MaxStepMatrixTests(unittest.TestCase):
expected_projection_count=2,
case_executor=_fake_completed,
)
def test_off_grid_checkpoint_is_rejected_before_execution(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError,
"not represented by the .* output grid",
):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01,),
additional_checkpoint_times=(0.0489,),
expected_projection_count=2,
case_executor=_fake_completed,
)
with self.assertRaisesRegex(RegressionManifestError, "must exceed"):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
@@ -5,24 +5,73 @@ import unittest
from unittest.mock import patch
from app.main import compile_reactflow_network
from app.simulation.components.amesim.flow.orifices import (
AmesimPnvo001SignalOpening,
)
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.registry import ComponentModelSpec, get_component_model_spec
from app.simulation.solvers.algebraic import (
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE,
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE,
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
PressureFlowSolver,
)
from app.simulation.systems.generic import GenericFluidSystem
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnl0001_xml import amesim_pnl0001_project
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
from tests.test_generic_system_xml_simulation import chain_project
class _UntrustedPnvo001(AmesimPnvo001SignalOpening):
# A module-name check alone would incorrectly trust this changed subclass.
__module__ = AmesimPnvo001SignalOpening.__module__
pressure_flow_callback_count = 0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
self.pressure_flow_callback_count += 1
return super().pressure_flow_equation_values()
class _InheritedDirectReaderPnl0001(AmesimPnl0001):
"""A changed concrete class that does not repeat the scalar promise."""
class _NonfiniteDirectReaderPnl0001(AmesimPnl0001):
__module__ = AmesimPnl0001.__module__
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.full_equation_evaluation_count = 0
self.direct_equation_evaluation_count = 0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
self.full_equation_evaluation_count += 1
return super().pressure_flow_equation_values()
def pressure_flow_equation_value_readers(self):
def nonfinite_pressure_state_residual() -> float:
self.direct_equation_evaluation_count += 1
return float("nan")
return {
f"{self.name}:port_2_pressure_state": (
nonfinite_pressure_state_residual
),
}
def _system(
project,
*,
executor_v2: bool | None = False,
coordinate_kernel: bool | None = None,
direct_sum_assignments: bool | None = None,
direct_equation_readers: bool | None = None,
) -> GenericFluidSystem:
environment = {}
if executor_v2 is not None:
@@ -33,6 +82,14 @@ def _system(
environment[CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE] = (
"1" if coordinate_kernel else "0"
)
if direct_sum_assignments is not None:
environment[CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE] = (
"1" if direct_sum_assignments else "0"
)
if direct_equation_readers is not None:
environment[CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE] = (
"1" if direct_equation_readers else "0"
)
with patch.dict(
os.environ,
environment,
@@ -44,10 +101,299 @@ def _system(
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
None,
)
if direct_sum_assignments is None:
os.environ.pop(
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE,
None,
)
if direct_equation_readers is None:
os.environ.pop(
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE,
None,
)
return GenericFluidSystem(compile_reactflow_network(project))
def _system_with_pnl0001_class(component_class, **options) -> GenericFluidSystem:
original_spec = get_component_model_spec("amesim_pnl0001")
custom_spec = ComponentModelSpec(
component_class=component_class,
library=original_spec.library,
)
def custom_spec_lookup(model_type: str):
if model_type == "amesim_pnl0001":
return custom_spec
return get_component_model_spec(model_type)
with patch(
"app.simulation.registry.get_component_model_spec",
side_effect=custom_spec_lookup,
):
return _system(amesim_pnl0001_project(), **options)
class PressureFlowCausalExecutionTests(unittest.TestCase):
def test_direct_equation_reader_is_default_on_and_bitwise_optional(
self,
) -> None:
direct = _system(
amesim_pnl0001_project(),
executor_v2=True,
direct_equation_readers=True,
)
callback = _system(
amesim_pnl0001_project(),
executor_v2=True,
direct_equation_readers=False,
)
direct_state = direct.initial_state_vector()
callback_state = callback.initial_state_vector()
for time in (0.0, 0.001):
self.assertEqual(
direct.rhs(time, direct_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in direct.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
direct_diagnostics = (
direct.pressure_flow_solver.causal_execution_diagnostics()
)
callback_diagnostics = (
callback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(direct_diagnostics["directEquationReadersConfigured"])
self.assertEqual(direct_diagnostics["directEffortAnchorCount"], 1)
self.assertFalse(
callback_diagnostics["directEquationReadersConfigured"]
)
self.assertEqual(callback_diagnostics["directEffortAnchorCount"], 0)
def test_inherited_direct_equation_reader_does_not_opt_in_subclass(
self,
) -> None:
system = _system_with_pnl0001_class(
_InheritedDirectReaderPnl0001,
direct_equation_readers=True,
)
pipe = system.network.components["pnl_1"]
solver = system.pressure_flow_solver
self.assertIsInstance(pipe, _InheritedDirectReaderPnl0001)
self.assertEqual(
solver.causal_execution_diagnostics()["directEffortAnchorCount"],
0,
)
anchor = next(
anchor
for group in solver._effort_groups["p"]
for anchor in group.anchors
if anchor.equation_id == "pnl_1:port_2_pressure_state"
)
self.assertIsNone(anchor.causal_evaluate)
def test_nonfinite_direct_effort_reader_uses_authoritative_fallback(
self,
) -> None:
system = _system_with_pnl0001_class(
_NonfiniteDirectReaderPnl0001,
executor_v2=True,
direct_equation_readers=True,
)
pipe = system.network.components["pnl_1"]
solver = system.pressure_flow_solver
self.assertIsInstance(pipe, _NonfiniteDirectReaderPnl0001)
self.assertEqual(
solver.causal_execution_diagnostics()["directEffortAnchorCount"],
1,
)
derivative = system.rhs(0.0, system.initial_state_vector())
self.assertTrue(all(value == value for value in derivative))
diagnostics = solver.causal_execution_diagnostics()
self.assertFalse(diagnostics["enabled"])
self.assertEqual(
diagnostics["disabledReason"],
"nonFiniteCausalEffortAnchor",
)
self.assertGreaterEqual(diagnostics["legacyFallbackCount"], 1)
self.assertGreater(pipe.direct_equation_evaluation_count, 0)
self.assertGreater(pipe.full_equation_evaluation_count, 0)
pipe.direct_equation_evaluation_count = 0
pipe.full_equation_evaluation_count = 0
solver.propagate_equal_efforts(("p",))
self.assertEqual(pipe.direct_equation_evaluation_count, 0)
self.assertEqual(pipe.full_equation_evaluation_count, 1)
def test_direct_sum_keeps_same_module_subclass_on_callback_plan(self) -> None:
original_spec = get_component_model_spec("amesim_pnvo001")
custom_spec = ComponentModelSpec(
component_class=_UntrustedPnvo001,
library=original_spec.library,
)
def custom_spec_lookup(model_type: str):
if model_type == "amesim_pnvo001":
return custom_spec
return get_component_model_spec(model_type)
with patch(
"app.simulation.registry.get_component_model_spec",
side_effect=custom_spec_lookup,
):
system = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
solver = system.pressure_flow_solver
valve = system.network.components["valve_1"]
self.assertIsInstance(valve, _UntrustedPnvo001)
assignment = next(
assignment
for stage in solver._explicit_flow_plan
for assignment in stage.assignments
if assignment.equation_id == "valve_1:mass_flow_balance"
)
self.assertIsNone(assignment.evaluate)
self.assertIs(assignment.component, valve)
self.assertEqual(
solver.causal_execution_diagnostics()[
"directSumFlowAssignmentCount"
],
0,
)
valve.pressure_flow_callback_count = 0
system.rhs(0.041, system.initial_state_vector())
self.assertGreater(valve.pressure_flow_callback_count, 0)
def test_direct_sum_assignments_match_disabled_path_bitwise(self) -> None:
direct = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
callback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=False,
)
direct_state = direct.initial_state_vector()
callback_state = callback.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
direct.rhs(time, direct_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in direct.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
direct_diagnostics = (
direct.pressure_flow_solver.causal_execution_diagnostics()
)
callback_diagnostics = (
callback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(
direct_diagnostics["directSumAssignmentsConfigured"]
)
self.assertFalse(
callback_diagnostics["directSumAssignmentsConfigured"]
)
self.assertGreater(
direct_diagnostics["directSumFlowAssignmentCount"],
0,
)
self.assertEqual(
callback_diagnostics["directSumFlowAssignmentCount"],
0,
)
self.assertEqual(
direct_diagnostics["compiledFlowAssignmentCount"],
callback_diagnostics["compiledFlowAssignmentCount"],
)
self.assertLess(
direct_diagnostics["directSumFlowAssignmentCount"],
direct_diagnostics["compiledFlowAssignmentCount"],
)
for key in (
"fastSolveCount",
"fullResidualAuditCount",
"auditFailureCount",
"legacyFallbackCount",
):
self.assertEqual(direct_diagnostics[key], callback_diagnostics[key])
self.assertGreater(direct_diagnostics["fastSolveCount"], 0)
self.assertEqual(direct_diagnostics["auditFailureCount"], 0)
self.assertEqual(direct_diagnostics["legacyFallbackCount"], 0)
def test_direct_sum_reader_rejection_uses_safe_component_fallback(
self,
) -> None:
callback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=False,
)
with patch.object(
PressureFlowSolver,
"_sum_to_zero_flow_target_reader",
side_effect=ValueError("synthetic unsupported sum"),
):
fallback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
callback_state = callback.initial_state_vector()
fallback_state = fallback.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
fallback.rhs(time, fallback_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in fallback.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
diagnostics = (
fallback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(diagnostics["directSumAssignmentsConfigured"])
self.assertEqual(diagnostics["directSumFlowAssignmentCount"], 0)
self.assertTrue(diagnostics["eligible"])
self.assertTrue(diagnostics["enabled"])
self.assertGreater(diagnostics["fastSolveCount"], 0)
self.assertEqual(diagnostics["auditFailureCount"], 0)
self.assertEqual(diagnostics["legacyFallbackCount"], 0)
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)
+56
View File
@@ -110,6 +110,62 @@ class TestMql8StaticRegressionTests(unittest.TestCase):
expected["hasMechanicalStateEvents"],
)
def test_contact_aware_mechanical_tolerance_plan_is_proof_gated(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(
compile_system_xml_network(self.validation.document)
)
reducer = system.mechanical_state_reducer
plan = reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
diagnostics = plan.as_dict()
self.assertEqual(len(plan.values), len(system.initial_state_vector()))
self.assertEqual(diagnostics["groupCount"], 10)
self.assertEqual(diagnostics["eligibleGroupCount"], 8)
self.assertEqual(diagnostics["relaxedVelocityStateCount"], 8)
self.assertEqual(diagnostics["relaxedPositionStateCount"], 0)
by_component = {
group.components[0]: group
for group in plan.groups
}
for index in range(1, 9):
group = by_component[f"amesim_mecmas21_{index}"]
self.assertTrue(group.eligible)
self.assertEqual(group.reason, "eligibleFlexibleContact")
self.assertAlmostEqual(group.velocity_atol, 1.0e-10)
self.assertEqual(group.position_atol, 1.0e-12)
self.assertAlmostEqual(
group.minimum_damping_strength_ratio,
10.0,
)
self.assertAlmostEqual(
group.minimum_force_limited_velocity_atol,
1.0e-10,
)
self.assertEqual(
group.contacts,
(f"amesim_lstp00a_{index}",),
)
for index in (9, 10):
group = by_component[f"amesim_mecmas21_{index}"]
self.assertFalse(group.eligible)
self.assertEqual(group.reason, "discreteEndstop")
self.assertEqual(group.velocity_atol, 1.0e-12)
self.assertEqual(group.position_atol, 1.0e-12)
legacy = reducer.absolute_tolerance_plan(1.0e-8, mode="legacy")
self.assertTrue(
all(
group.velocity_atol == 1.0e-12
and group.position_atol == 1.0e-12
for group in legacy.groups
)
)
def test_signal_event_schedule_is_fixed_for_every_horizon(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(