整合求解器活动监控与步长回归证据
同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。 纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
This commit is contained in:
1 parent
c19cf77aee
commit
e18399c022
46 files changed
+181589
-170
No files matched your search
+55
-21
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 的实时分类仍待实现。
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+30317
File diff suppressed because it is too large.
Load diff
+38274
File diff suppressed because it is too large.
Load diff
+65793
File diff suppressed because it is too large.
Load diff
+86
@@ -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."
|
||||
]
|
||||
}
|
||||
+321
@@ -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
|
||||
}
|
||||
}
|
||||
+8563
File diff suppressed because it is too large.
Load diff
+8722
File diff suppressed because it is too large.
Load diff
+8517
File diff suppressed because it is too large.
Load diff
+8767
File diff suppressed because it is too large.
Load diff
+8595
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user