整合求解器活动监控与步长回归证据
同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。 纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
This commit is contained in:
1 parent
c19cf77aee
commit
e18399c022
46 files changed
+181571
-152
No files matched your search
+41
-7
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import csv
|
import csv
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import io
|
import io
|
||||||
import json
|
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.performance import performance_span, profile_phase, profile_run
|
||||||
from app.simulation.property_cache import property_cache_run
|
from app.simulation.property_cache import property_cache_run
|
||||||
|
from app.simulation.solvers.solver import SolverActivityTracker
|
||||||
from app.system_xml import (
|
from app.system_xml import (
|
||||||
SystemXmlDocument,
|
SystemXmlDocument,
|
||||||
SystemXmlValidationReport,
|
SystemXmlValidationReport,
|
||||||
@@ -86,6 +87,9 @@ SimulationTaskStatus = Literal[
|
|||||||
class SimulationTaskRecord:
|
class SimulationTaskRecord:
|
||||||
simulation_id: str
|
simulation_id: str
|
||||||
cancel_event: threading.Event
|
cancel_event: threading.Event
|
||||||
|
activity_tracker: SolverActivityTracker = field(
|
||||||
|
default_factory=SolverActivityTracker
|
||||||
|
)
|
||||||
status: SimulationTaskStatus = "queued"
|
status: SimulationTaskStatus = "queued"
|
||||||
cancel_reason: SimulationCancelReason | None = None
|
cancel_reason: SimulationCancelReason | None = None
|
||||||
result: dict[str, object] | 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,
|
"cancelReason": task.cancel_reason,
|
||||||
"result": task.result,
|
"result": task.result,
|
||||||
"error": task.error,
|
"error": task.error,
|
||||||
|
**task.activity_tracker.snapshot().as_dict(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -681,6 +686,7 @@ def run_system_xml_simulation(
|
|||||||
xml_bytes: bytes,
|
xml_bytes: bytes,
|
||||||
progress_callback: SimulationProgressEmitter | None = None,
|
progress_callback: SimulationProgressEmitter | None = None,
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
with property_cache_run() as property_cache:
|
with property_cache_run() as property_cache:
|
||||||
with profile_run() as trace:
|
with profile_run() as trace:
|
||||||
@@ -688,6 +694,7 @@ def run_system_xml_simulation(
|
|||||||
xml_bytes,
|
xml_bytes,
|
||||||
progress_callback,
|
progress_callback,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
|
activity_tracker,
|
||||||
)
|
)
|
||||||
|
|
||||||
performance = trace.snapshot()
|
performance = trace.snapshot()
|
||||||
@@ -712,6 +719,7 @@ def _run_system_xml_simulation_profiled(
|
|||||||
xml_bytes: bytes,
|
xml_bytes: bytes,
|
||||||
progress_callback: SimulationProgressEmitter | None = None,
|
progress_callback: SimulationProgressEmitter | None = None,
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
from app.simulation.solvers.algebraic import AlgebraicSolveError
|
||||||
from app.simulation.solvers.solver import SolveIVPConfig
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
@@ -774,6 +782,7 @@ def _run_system_xml_simulation_profiled(
|
|||||||
sample_step=document.simulation.sample_step,
|
sample_step=document.simulation.sample_step,
|
||||||
progress_callback=report_system_progress,
|
progress_callback=report_system_progress,
|
||||||
cancel_check=cancel_check,
|
cancel_check=cancel_check,
|
||||||
|
activity_tracker=activity_tracker,
|
||||||
)
|
)
|
||||||
except SimulationPreparationError as exc:
|
except SimulationPreparationError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -862,6 +871,9 @@ def simulation_event_stream(
|
|||||||
latest_message = "正在等待仿真任务启动"
|
latest_message = "正在等待仿真任务启动"
|
||||||
latest_simulated_time: float | None = None
|
latest_simulated_time: float | None = None
|
||||||
latest_total_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(
|
def emit_progress(
|
||||||
progress: int,
|
progress: int,
|
||||||
@@ -889,6 +901,7 @@ def simulation_event_stream(
|
|||||||
event["simulatedTime"] = latest_simulated_time
|
event["simulatedTime"] = latest_simulated_time
|
||||||
if latest_total_time is not None:
|
if latest_total_time is not None:
|
||||||
event["totalTime"] = latest_total_time
|
event["totalTime"] = latest_total_time
|
||||||
|
event.update(activity_tracker.snapshot().as_dict())
|
||||||
events.put(event)
|
events.put(event)
|
||||||
|
|
||||||
def worker() -> None:
|
def worker() -> None:
|
||||||
@@ -899,18 +912,32 @@ def simulation_event_stream(
|
|||||||
xml_bytes,
|
xml_bytes,
|
||||||
emit_progress,
|
emit_progress,
|
||||||
task.cancel_event.is_set if task is not None else None,
|
task.cancel_event.is_set if task is not None else None,
|
||||||
|
activity_tracker,
|
||||||
)
|
)
|
||||||
if task is not None:
|
if task is not None:
|
||||||
result = _mark_simulation_task_result(task, result)
|
result = _mark_simulation_task_result(task, result)
|
||||||
result_status = str(result.get("status", "completed"))
|
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 = {
|
result_messages = {
|
||||||
"completed": "仿真完成",
|
"completed": "仿真完成",
|
||||||
"stopped": "仿真已由用户终止,已保留部分结果",
|
"stopped": "仿真已由用户终止,已保留部分结果",
|
||||||
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
|
"stalled": "仿真因进度连接异常而终止,已保留部分结果",
|
||||||
"failed": "仿真异常终止,已保留可用的部分结果",
|
"failed": "仿真异常终止,已保留可用的部分结果",
|
||||||
}
|
}
|
||||||
events.put(
|
result_event = {
|
||||||
{
|
|
||||||
"event": "result",
|
"event": "result",
|
||||||
"progress": 100 if result_status == "completed" else latest_progress,
|
"progress": 100 if result_status == "completed" else latest_progress,
|
||||||
"phase": result_status,
|
"phase": result_status,
|
||||||
@@ -919,8 +946,10 @@ def simulation_event_stream(
|
|||||||
"totalTime": result.get("requestedStopTime"),
|
"totalTime": result.get("requestedStopTime"),
|
||||||
"result": result,
|
"result": result,
|
||||||
}
|
}
|
||||||
)
|
result_event.update(activity_tracker.snapshot().as_dict())
|
||||||
|
events.put(result_event)
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
|
activity_tracker.record_phase("failed")
|
||||||
detail = exc.detail
|
detail = exc.detail
|
||||||
message = (
|
message = (
|
||||||
str(detail.get("message", "仿真失败"))
|
str(detail.get("message", "仿真失败"))
|
||||||
@@ -935,10 +964,12 @@ def simulation_event_stream(
|
|||||||
"message": message,
|
"message": message,
|
||||||
"detail": detail,
|
"detail": detail,
|
||||||
}
|
}
|
||||||
|
error_event.update(activity_tracker.snapshot().as_dict())
|
||||||
if task is not None:
|
if task is not None:
|
||||||
_mark_simulation_task_error(task, error_event)
|
_mark_simulation_task_error(task, error_event)
|
||||||
events.put(error_event)
|
events.put(error_event)
|
||||||
except Exception as exc: # pragma: no cover - last-resort stream guard
|
except Exception as exc: # pragma: no cover - last-resort stream guard
|
||||||
|
activity_tracker.record_phase("failed")
|
||||||
error_event = {
|
error_event = {
|
||||||
"event": "error",
|
"event": "error",
|
||||||
"progress": latest_progress,
|
"progress": latest_progress,
|
||||||
@@ -947,6 +978,7 @@ def simulation_event_stream(
|
|||||||
"message": "仿真服务发生未预期错误。",
|
"message": "仿真服务发生未预期错误。",
|
||||||
"detail": str(exc),
|
"detail": str(exc),
|
||||||
}
|
}
|
||||||
|
error_event.update(activity_tracker.snapshot().as_dict())
|
||||||
if task is not None:
|
if task is not None:
|
||||||
_mark_simulation_task_error(task, error_event)
|
_mark_simulation_task_error(task, error_event)
|
||||||
events.put(error_event)
|
events.put(error_event)
|
||||||
@@ -964,8 +996,7 @@ def simulation_event_stream(
|
|||||||
try:
|
try:
|
||||||
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
|
event = events.get(timeout=SIMULATION_STREAM_HEARTBEAT_SECONDS)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
yield json.dumps(
|
heartbeat_event = {
|
||||||
{
|
|
||||||
"event": "progress",
|
"event": "progress",
|
||||||
"progress": latest_progress,
|
"progress": latest_progress,
|
||||||
"phase": latest_phase,
|
"phase": latest_phase,
|
||||||
@@ -973,7 +1004,10 @@ def simulation_event_stream(
|
|||||||
"heartbeat": True,
|
"heartbeat": True,
|
||||||
"simulatedTime": latest_simulated_time,
|
"simulatedTime": latest_simulated_time,
|
||||||
"totalTime": latest_total_time,
|
"totalTime": latest_total_time,
|
||||||
},
|
}
|
||||||
|
heartbeat_event.update(activity_tracker.snapshot().as_dict())
|
||||||
|
yield json.dumps(
|
||||||
|
heartbeat_event,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
) + "\n"
|
) + "\n"
|
||||||
|
|||||||
@@ -1038,9 +1038,18 @@ def runtime_snapshot() -> dict[str, object]:
|
|||||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL": os.getenv(
|
"SIMULATION_CAUSAL_COORDINATE_KERNEL": os.getenv(
|
||||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL", "1"
|
"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": os.getenv(
|
||||||
"SIMULATION_CAUSAL_FAST_PATH", "1"
|
"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_TYPE = "amesim_pnor001"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -465,6 +468,9 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "amesim_pnvo001_fixed"
|
MODEL_TYPE = "amesim_pnvo001_fixed"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
|
||||||
@@ -900,6 +906,10 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
|
|||||||
MODEL_TYPE = "amesim_pnvo001"
|
MODEL_TYPE = "amesim_pnvo001"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
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 = (
|
PORTS = (
|
||||||
PortDefinition.signal("res", nominal_role="input"),
|
PortDefinition.signal("res", nominal_role="input"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from math import isclose, isfinite, log, log10, pi, sqrt, tanh
|
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
|
_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:
|
def _reported_friction_factor(value: float) -> float:
|
||||||
return min(float(value), _MAX_REPORTED_FRICTION_FACTOR)
|
return min(float(value), _MAX_REPORTED_FRICTION_FACTOR)
|
||||||
|
|
||||||
@@ -123,6 +164,9 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "amesim_pnl00r"
|
MODEL_TYPE = "amesim_pnl00r"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = True
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -876,11 +920,11 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
value *= tanh(max(smoothing_argument, 0.0))
|
value *= tanh(max(smoothing_argument, 0.0))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
pressure_ratio_flow_parameter = mass_flow_parameter(pressure_ratio)
|
||||||
if analytic_laminar:
|
if analytic_laminar:
|
||||||
flow_parameter = mass_flow_parameter(pressure_ratio)
|
|
||||||
viscosity = self._dynamic_viscosity(T_up)
|
viscosity = self._dynamic_viscosity(T_up)
|
||||||
laminar_mass_flow = (
|
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)
|
) ** 2 / (16.0 * pi * viscosity * resistance_length * T_up)
|
||||||
if (
|
if (
|
||||||
self.reynolds_number(laminar_mass_flow, T_up)
|
self.reynolds_number(laminar_mass_flow, T_up)
|
||||||
@@ -888,27 +932,58 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
):
|
):
|
||||||
return laminar_mass_flow
|
return laminar_mass_flow
|
||||||
|
|
||||||
def target_flow(mass_flow: float) -> float:
|
sqrt_temperature = sqrt(T_up)
|
||||||
reynolds = self.reynolds_number(mass_flow, T_up)
|
compiled_reynolds = (
|
||||||
friction = self.friction_factor(reynolds)
|
type(self).reynolds_number is AmesimPnl0001.reynolds_number
|
||||||
flow_coefficient = sqrt(
|
and type(self)._dynamic_viscosity is AmesimPnl0001._dynamic_viscosity
|
||||||
self.diam / (resistance_length * friction)
|
|
||||||
)
|
)
|
||||||
return (
|
reynolds_denominator = (
|
||||||
flow_coefficient
|
pi * self.diam * self._dynamic_viscosity(T_up)
|
||||||
* self.area
|
if max_iterations > 0 and compiled_reynolds
|
||||||
* p_up
|
else None
|
||||||
* mass_flow_parameter(pressure_ratio)
|
)
|
||||||
/ sqrt(T_up)
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
flow_coefficient = sqrt(self.diam / (resistance_length * 0.02))
|
def target_flow(mass_flow: float) -> float:
|
||||||
magnitude = (
|
reynolds = (
|
||||||
flow_coefficient
|
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 (
|
||||||
|
sqrt(self.diam / (resistance_length * friction))
|
||||||
* self.area
|
* self.area
|
||||||
* p_up
|
* p_up
|
||||||
* mass_flow_parameter(pressure_ratio)
|
* pressure_ratio_flow_parameter
|
||||||
/ sqrt(T_up)
|
/ sqrt_temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
magnitude = (
|
||||||
|
sqrt(self.diam / (resistance_length * 0.02))
|
||||||
|
* self.area
|
||||||
|
* p_up
|
||||||
|
* pressure_ratio_flow_parameter
|
||||||
|
/ sqrt_temperature
|
||||||
)
|
)
|
||||||
for _iteration in range(max_iterations):
|
for _iteration in range(max_iterations):
|
||||||
next_magnitude = target_flow(magnitude)
|
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),
|
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, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume)
|
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
|
|||||||
MODEL_TYPE = "amesim_pn3node2"
|
MODEL_TYPE = "amesim_pn3node2"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -200,6 +203,9 @@ class AmesimP4Node2(_AmesimPneumaticNode):
|
|||||||
MODEL_TYPE = "amesim_p4node2"
|
MODEL_TYPE = "amesim_p4node2"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -1386,6 +1386,9 @@ class AmesimLmechn1(AlgebraicComponent):
|
|||||||
|
|
||||||
MODEL_TYPE = "amesim_lmechn1"
|
MODEL_TYPE = "amesim_lmechn1"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.2.0"
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("force_balance",)
|
||||||
|
)
|
||||||
PORTS = tuple(
|
PORTS = tuple(
|
||||||
PortDefinition.mechanical_translational(f"port_{index}")
|
PortDefinition.mechanical_translational(f"port_{index}")
|
||||||
for index in range(1, 22)
|
for index in range(1, 22)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class Orifice(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "orifice"
|
MODEL_TYPE = "orifice"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
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:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
self.port_a.h_outflow = connected_h["port_b"]
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
self.port_b.h_outflow = connected_h["port_a"]
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
|
|
||||||
@@ -17,6 +17,9 @@ class ResistivePipe(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "pipe"
|
MODEL_TYPE = "pipe"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class Tee(AlgebraicComponent):
|
|||||||
MODEL_TYPE = "tee"
|
MODEL_TYPE = "tee"
|
||||||
MODEL_VERSION = "1.0.0"
|
MODEL_VERSION = "1.0.0"
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
PRESSURE_FLOW_DEPENDS_ON_STREAM = False
|
||||||
|
PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset(
|
||||||
|
("mass_flow_balance",)
|
||||||
|
)
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
from app.simulation.core.catalog import ComponentDisplaySpec
|
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
|
# they override either stream hook, the closure planner retains the legacy
|
||||||
# full-network thermofluid fixed point.
|
# full-network thermofluid fixed point.
|
||||||
PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None
|
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, ...]] = ()
|
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||||
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||||
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||||
@@ -245,6 +252,18 @@ class Component(ABC):
|
|||||||
for equation in self.pressure_flow_equation_residuals()
|
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:
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
"""Update connector outflow properties from current flow directions."""
|
"""Update connector outflow properties from current flow directions."""
|
||||||
|
|
||||||
|
|||||||
@@ -399,6 +399,19 @@ def _case_observation(
|
|||||||
checkpoint_details.append(detail)
|
checkpoint_details.append(detail)
|
||||||
if not detail["available"]:
|
if not detail["available"]:
|
||||||
issues.append("checkpointUnavailable")
|
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(
|
if expected_projection_count is not None and len(state_values) != int(
|
||||||
expected_projection_count
|
expected_projection_count
|
||||||
):
|
):
|
||||||
@@ -455,6 +468,7 @@ def _pair_checkpoints(
|
|||||||
right: Mapping[str, object],
|
right: Mapping[str, object],
|
||||||
*,
|
*,
|
||||||
tolerance: MatrixTolerance,
|
tolerance: MatrixTolerance,
|
||||||
|
strict_prefix_stop: float | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
left_checkpoints = _checkpoints(_summary(left))
|
left_checkpoints = _checkpoints(_summary(left))
|
||||||
right_checkpoints = _checkpoints(_summary(right))
|
right_checkpoints = _checkpoints(_summary(right))
|
||||||
@@ -463,6 +477,11 @@ def _pair_checkpoints(
|
|||||||
left_time = left_checkpoint.get("requestedTime")
|
left_time = left_checkpoint.get("requestedTime")
|
||||||
if not isinstance(left_time, (int, float)):
|
if not isinstance(left_time, (int, float)):
|
||||||
continue
|
continue
|
||||||
|
if strict_prefix_stop is not None and not (
|
||||||
|
float(left_time)
|
||||||
|
< strict_prefix_stop - tolerance.checkpoint_time_absolute_seconds
|
||||||
|
):
|
||||||
|
continue
|
||||||
match = next(
|
match = next(
|
||||||
(
|
(
|
||||||
right_checkpoint
|
right_checkpoint
|
||||||
@@ -554,6 +573,7 @@ def _pair_checkpoints(
|
|||||||
"commonCheckpointTimes": [
|
"commonCheckpointTimes": [
|
||||||
pair[0].get("requestedTime") for pair in paired
|
pair[0].get("requestedTime") for pair in paired
|
||||||
],
|
],
|
||||||
|
"strictPrefixStopTime": strict_prefix_stop,
|
||||||
"valueMismatchCount": mismatch_count,
|
"valueMismatchCount": mismatch_count,
|
||||||
"keySetMismatchCount": key_set_mismatch_count,
|
"keySetMismatchCount": key_set_mismatch_count,
|
||||||
"nonnumericValueCount": nonnumeric_count,
|
"nonnumericValueCount": nonnumeric_count,
|
||||||
@@ -569,6 +589,7 @@ def _compare_time_sequences(
|
|||||||
*,
|
*,
|
||||||
prefix_stop: float,
|
prefix_stop: float,
|
||||||
absolute_tolerance: float,
|
absolute_tolerance: float,
|
||||||
|
strict_prefix: bool,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
left_times = _finite_event_times(left)
|
left_times = _finite_event_times(left)
|
||||||
right_times = _finite_event_times(right)
|
right_times = _finite_event_times(right)
|
||||||
@@ -579,12 +600,12 @@ def _compare_time_sequences(
|
|||||||
"left": left,
|
"left": left,
|
||||||
"right": right,
|
"right": right,
|
||||||
}
|
}
|
||||||
left_prefix = [
|
if strict_prefix:
|
||||||
value for value in left_times if value <= prefix_stop + absolute_tolerance
|
include = lambda value: value < prefix_stop - absolute_tolerance
|
||||||
]
|
else:
|
||||||
right_prefix = [
|
include = lambda value: value <= prefix_stop + absolute_tolerance
|
||||||
value for value in right_times if 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(
|
passed = len(left_prefix) == len(right_prefix) and all(
|
||||||
math.isclose(
|
math.isclose(
|
||||||
left_value,
|
left_value,
|
||||||
@@ -607,6 +628,7 @@ def _pair_events(
|
|||||||
right: Mapping[str, object],
|
right: Mapping[str, object],
|
||||||
*,
|
*,
|
||||||
prefix_stop: float,
|
prefix_stop: float,
|
||||||
|
strict_prefix: bool,
|
||||||
tolerance: MatrixTolerance,
|
tolerance: MatrixTolerance,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
left_trace = _event_trace(_summary(left))
|
left_trace = _event_trace(_summary(left))
|
||||||
@@ -616,12 +638,14 @@ def _pair_events(
|
|||||||
right_trace.get("signalEventTimes"),
|
right_trace.get("signalEventTimes"),
|
||||||
prefix_stop=prefix_stop,
|
prefix_stop=prefix_stop,
|
||||||
absolute_tolerance=tolerance.signal_event_time_absolute_seconds,
|
absolute_tolerance=tolerance.signal_event_time_absolute_seconds,
|
||||||
|
strict_prefix=strict_prefix,
|
||||||
)
|
)
|
||||||
mechanical = _compare_time_sequences(
|
mechanical = _compare_time_sequences(
|
||||||
left_trace.get("mechanicalTransitionTimes"),
|
left_trace.get("mechanicalTransitionTimes"),
|
||||||
right_trace.get("mechanicalTransitionTimes"),
|
right_trace.get("mechanicalTransitionTimes"),
|
||||||
prefix_stop=prefix_stop,
|
prefix_stop=prefix_stop,
|
||||||
absolute_tolerance=tolerance.event_time_absolute_seconds,
|
absolute_tolerance=tolerance.event_time_absolute_seconds,
|
||||||
|
strict_prefix=strict_prefix,
|
||||||
)
|
)
|
||||||
availability = (
|
availability = (
|
||||||
left_trace.get("mechanicalTransitionTimesAvailable") is not False
|
left_trace.get("mechanicalTransitionTimesAvailable") is not False
|
||||||
@@ -631,6 +655,7 @@ def _pair_events(
|
|||||||
"evaluated": bool(left_trace) and bool(right_trace),
|
"evaluated": bool(left_trace) and bool(right_trace),
|
||||||
"passed": bool(signal["passed"] and mechanical["passed"] and availability),
|
"passed": bool(signal["passed"] and mechanical["passed"] and availability),
|
||||||
"prefixStopTime": prefix_stop,
|
"prefixStopTime": prefix_stop,
|
||||||
|
"strictPrefix": strict_prefix,
|
||||||
"signalEventTimes": signal,
|
"signalEventTimes": signal,
|
||||||
"mechanicalTransitionTimes": mechanical,
|
"mechanicalTransitionTimes": mechanical,
|
||||||
"mechanicalTransitionTimesAvailable": availability,
|
"mechanicalTransitionTimesAvailable": availability,
|
||||||
@@ -701,17 +726,29 @@ def compare_matrix_cases(
|
|||||||
left_stop = float(left["stopTime"])
|
left_stop = float(left["stopTime"])
|
||||||
right_stop = float(right["stopTime"])
|
right_stop = float(right["stopTime"])
|
||||||
prefix_stop = min(left_stop, right_stop)
|
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):
|
if not _completed(left, left_stop) or not _completed(right, right_stop):
|
||||||
return {
|
return {
|
||||||
"evaluated": False,
|
"evaluated": False,
|
||||||
"passed": False,
|
"passed": False,
|
||||||
"reason": "oneOrBothCasesDidNotComplete",
|
"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(
|
events = _pair_events(
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
prefix_stop=prefix_stop,
|
prefix_stop=prefix_stop,
|
||||||
|
strict_prefix=strict_prefix,
|
||||||
tolerance=tolerance,
|
tolerance=tolerance,
|
||||||
)
|
)
|
||||||
diagnostics = _pair_diagnostics(
|
diagnostics = _pair_diagnostics(
|
||||||
@@ -728,6 +765,7 @@ def compare_matrix_cases(
|
|||||||
"evaluated": True,
|
"evaluated": True,
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
"commonPrefixStopTime": prefix_stop,
|
"commonPrefixStopTime": prefix_stop,
|
||||||
|
"strictPrefix": strict_prefix,
|
||||||
"stateProjection": state,
|
"stateProjection": state,
|
||||||
"events": events,
|
"events": events,
|
||||||
"diagnostics": diagnostics,
|
"diagnostics": diagnostics,
|
||||||
@@ -874,6 +912,36 @@ def run_max_step_matrix(
|
|||||||
if sampling_mode == "source"
|
if sampling_mode == "source"
|
||||||
else _finite_positive(lane_config.get("sampleStep"), field="lane.sampleStep")
|
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(
|
instrumentation_mode = str(
|
||||||
lane_config.get("instrumentationMode", "standard")
|
lane_config.get("instrumentationMode", "standard")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_EXECUTOR_V2"
|
|||||||
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = (
|
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = (
|
||||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL"
|
"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
|
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"}
|
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):
|
class AlgebraicSolveError(RuntimeError):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -127,6 +147,7 @@ class EffortAnchor:
|
|||||||
unknown: AlgebraicUnknown
|
unknown: AlgebraicUnknown
|
||||||
evaluate: Callable[[], float]
|
evaluate: Callable[[], float]
|
||||||
equation_id: str
|
equation_id: str
|
||||||
|
causal_evaluate: Callable[[], float] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -326,6 +347,14 @@ class PressureFlowSolver:
|
|||||||
self.residual_tolerance = residual_tolerance
|
self.residual_tolerance = residual_tolerance
|
||||||
self.max_evaluations = max_evaluations
|
self.max_evaluations = max_evaluations
|
||||||
self.scope_kind = scope_kind
|
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 = self._build_unknowns()
|
||||||
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
|
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
|
||||||
self._unknowns_by_variable = {
|
self._unknowns_by_variable = {
|
||||||
@@ -371,6 +400,9 @@ class PressureFlowSolver:
|
|||||||
for item in self._component_equation_plan
|
for item in self._component_equation_plan
|
||||||
for equation in item.templates
|
for equation in item.templates
|
||||||
) + tuple(item.template for item in self._connection_equation_plan)
|
) + tuple(item.template for item in self._connection_equation_plan)
|
||||||
|
self._causal_direct_equation_readers = (
|
||||||
|
self._build_causal_direct_equation_readers()
|
||||||
|
)
|
||||||
self._effort_groups = {
|
self._effort_groups = {
|
||||||
variable: self._build_effort_equality_groups(variable)
|
variable: self._build_effort_equality_groups(variable)
|
||||||
for variable in ("p", "x", "v")
|
for variable in ("p", "x", "v")
|
||||||
@@ -418,6 +450,11 @@ class PressureFlowSolver:
|
|||||||
self._causal_fast_path_eligible,
|
self._causal_fast_path_eligible,
|
||||||
self._causal_fast_path_fallback_reason,
|
self._causal_fast_path_fallback_reason,
|
||||||
) = self._build_causal_execution_plan()
|
) = 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 = (
|
self._causal_fast_path_environment_enabled = (
|
||||||
_causal_fast_path_environment_enabled()
|
_causal_fast_path_environment_enabled()
|
||||||
)
|
)
|
||||||
@@ -599,6 +636,18 @@ class PressureFlowSolver:
|
|||||||
"coordinateKernelFastSolveCount": (
|
"coordinateKernelFastSolveCount": (
|
||||||
self._causal_coordinate_fast_solve_count
|
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": (
|
"compiledEffortUnknownCount": (
|
||||||
self._causal_compiled_effort_unknown_count
|
self._causal_compiled_effort_unknown_count
|
||||||
),
|
),
|
||||||
@@ -850,6 +899,9 @@ class PressureFlowSolver:
|
|||||||
] = {}
|
] = {}
|
||||||
component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {}
|
component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {}
|
||||||
for coordinate_index, assignment in indexed_assignments:
|
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[
|
equation_index = equation_index_by_id[
|
||||||
assignment.anchor.equation_id
|
assignment.anchor.equation_id
|
||||||
]
|
]
|
||||||
@@ -934,10 +986,22 @@ class PressureFlowSolver:
|
|||||||
if stage is None:
|
if stage is None:
|
||||||
return False
|
return False
|
||||||
for coordinate_index, assignment in stage.direct_targets:
|
for coordinate_index, assignment in stage.direct_targets:
|
||||||
|
evaluate = (
|
||||||
|
assignment.anchor.causal_evaluate
|
||||||
|
if assignment.anchor.causal_evaluate is not None
|
||||||
|
else assignment.anchor.evaluate
|
||||||
|
)
|
||||||
|
try:
|
||||||
workspace[coordinate_index] = (
|
workspace[coordinate_index] = (
|
||||||
self._read_effort_anchor(assignment)
|
self._read_effort_anchor(assignment)
|
||||||
- assignment.anchor.evaluate()
|
- evaluate()
|
||||||
)
|
)
|
||||||
|
except MemoryError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
if assignment.anchor.causal_evaluate is None:
|
||||||
|
raise
|
||||||
|
return False
|
||||||
for evaluation in stage.component_evaluations:
|
for evaluation in stage.component_evaluations:
|
||||||
equation_values = evaluation.evaluate()
|
equation_values = evaluation.evaluate()
|
||||||
for target in evaluation.targets:
|
for target in evaluation.targets:
|
||||||
@@ -975,7 +1039,19 @@ class PressureFlowSolver:
|
|||||||
return False
|
return False
|
||||||
for assignment in assignments:
|
for assignment in assignments:
|
||||||
anchor = assignment.anchor
|
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 (
|
if not isfinite(target) or (
|
||||||
variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
|
variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
|
||||||
):
|
):
|
||||||
@@ -1431,6 +1507,9 @@ class PressureFlowSolver:
|
|||||||
unknown=unknown,
|
unknown=unknown,
|
||||||
evaluate=self._equation_value_reader(equation),
|
evaluate=self._equation_value_reader(equation),
|
||||||
equation_id=equation.id,
|
equation_id=equation.id,
|
||||||
|
causal_evaluate=(
|
||||||
|
self._causal_direct_equation_readers.get(equation.id)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1851,6 +1930,45 @@ class PressureFlowSolver:
|
|||||||
|
|
||||||
return read_component_equation
|
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(
|
def _connection_flow_target_reader(
|
||||||
self,
|
self,
|
||||||
equation,
|
equation,
|
||||||
@@ -1870,6 +1988,69 @@ class PressureFlowSolver:
|
|||||||
f"Connection flow equation {equation.id} does not contain {unknown.id}."
|
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, ...]:
|
def _pressure_flow_equation_values(self) -> tuple[float, ...]:
|
||||||
"""Evaluate live equation values through the compiled topology."""
|
"""Evaluate live equation values through the compiled topology."""
|
||||||
|
|
||||||
@@ -1918,6 +2099,29 @@ class PressureFlowSolver:
|
|||||||
|
|
||||||
evaluation = self._component_equation_plans_by_id[equation.owner_id]
|
evaluation = self._component_equation_plans_by_id[equation.owner_id]
|
||||||
component = evaluation.component
|
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
|
equations = evaluation.templates
|
||||||
equation_ids = tuple(current.id for current in equations)
|
equation_ids = tuple(current.id for current in equations)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -378,13 +378,7 @@ class CausalNumericIR:
|
|||||||
workspace.effort_written[output] = True
|
workspace.effort_written[output] = True
|
||||||
except MemoryError:
|
except MemoryError:
|
||||||
raise
|
raise
|
||||||
except (
|
except Exception as exc:
|
||||||
ArithmeticError,
|
|
||||||
IndexError,
|
|
||||||
RuntimeError,
|
|
||||||
TypeError,
|
|
||||||
ValueError,
|
|
||||||
) as exc:
|
|
||||||
return failed(f"effortEvaluationFailed:{type(exc).__name__}")
|
return failed(f"effortEvaluationFailed:{type(exc).__name__}")
|
||||||
if any(not bool(workspace.effort_written[index]) for index in range(width)):
|
if any(not bool(workspace.effort_written[index]) for index in range(width)):
|
||||||
return failed("effortEvaluationCoverageMismatch")
|
return failed("effortEvaluationCoverageMismatch")
|
||||||
@@ -530,6 +524,7 @@ def _unique_slots(items: Iterable[int]) -> tuple[int, ...]:
|
|||||||
def _compile_effort_evaluations(
|
def _compile_effort_evaluations(
|
||||||
operations: tuple[CausalIREffortOperation, ...],
|
operations: tuple[CausalIREffortOperation, ...],
|
||||||
anchor_evaluators: tuple[Callable[[], float], ...],
|
anchor_evaluators: tuple[Callable[[], float], ...],
|
||||||
|
direct_residuals: tuple[bool, ...],
|
||||||
component_locations: dict[
|
component_locations: dict[
|
||||||
str, tuple[object, Callable[[], tuple[float, ...]], int]
|
str, tuple[object, Callable[[], tuple[float, ...]], int]
|
||||||
],
|
],
|
||||||
@@ -538,10 +533,14 @@ def _compile_effort_evaluations(
|
|||||||
grouped: dict[int, list[tuple[int, int, str]]] = {}
|
grouped: dict[int, list[tuple[int, int, str]]] = {}
|
||||||
component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {}
|
component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {}
|
||||||
direct: list[tuple[int, Callable[[], float], str]] = []
|
direct: list[tuple[int, Callable[[], float], str]] = []
|
||||||
for output, (operation, anchor_evaluate) in enumerate(
|
for output, (operation, anchor_evaluate, direct_residual) in enumerate(
|
||||||
zip(operations, anchor_evaluators)
|
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:
|
if location is None:
|
||||||
direct.append((output, anchor_evaluate, operation.equation_id))
|
direct.append((output, anchor_evaluate, operation.equation_id))
|
||||||
continue
|
continue
|
||||||
@@ -638,6 +637,7 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
|
|||||||
for variable in ("p", "x", "v"):
|
for variable in ("p", "x", "v"):
|
||||||
operations: list[CausalIREffortOperation] = []
|
operations: list[CausalIREffortOperation] = []
|
||||||
anchors: list[Callable[[], float]] = []
|
anchors: list[Callable[[], float]] = []
|
||||||
|
direct_residuals: list[bool] = []
|
||||||
for assignment in effort_plan[variable]:
|
for assignment in effort_plan[variable]:
|
||||||
result = len(canonical_slots)
|
result = len(canonical_slots)
|
||||||
equation_id = str(assignment.anchor.equation_id)
|
equation_id = str(assignment.anchor.equation_id)
|
||||||
@@ -665,7 +665,17 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
|
|||||||
equation_id,
|
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)
|
operation_tuple = tuple(operations)
|
||||||
effort_stages.append(
|
effort_stages.append(
|
||||||
CausalIREffortStage(
|
CausalIREffortStage(
|
||||||
@@ -674,6 +684,7 @@ def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation:
|
|||||||
_compile_effort_evaluations(
|
_compile_effort_evaluations(
|
||||||
operation_tuple,
|
operation_tuple,
|
||||||
tuple(anchors),
|
tuple(anchors),
|
||||||
|
tuple(direct_residuals),
|
||||||
component_locations,
|
component_locations,
|
||||||
evaluators,
|
evaluators,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from math import isfinite
|
||||||
|
import os
|
||||||
from typing import Callable, Literal, Mapping, Sequence
|
from typing import Callable, Literal, Mapping, Sequence
|
||||||
|
|
||||||
from app.simulation.components.amesim.mechanical.translational import (
|
from app.simulation.components.amesim.mechanical.translational import (
|
||||||
|
AmesimLstp00a,
|
||||||
AmesimMecmas21,
|
AmesimMecmas21,
|
||||||
)
|
)
|
||||||
from app.simulation.core.base import DynamicComponent
|
from app.simulation.core.base import DynamicComponent
|
||||||
@@ -12,15 +15,114 @@ from app.simulation.systems.network import SimulationNetwork
|
|||||||
|
|
||||||
|
|
||||||
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
|
ConstraintMode = Literal["uninitialized", "free", "lower", "upper"]
|
||||||
|
MechanicalAbsoluteToleranceMode = Literal["legacy", "contact-aware-v1"]
|
||||||
DenseState = Callable[[float], Sequence[float]]
|
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
|
@dataclass
|
||||||
class MechanicalConstraintGroup:
|
class MechanicalConstraintGroup:
|
||||||
"""MECMAS21 inertias that share one rigid translational coordinate."""
|
"""MECMAS21 inertias that share one rigid translational coordinate."""
|
||||||
|
|
||||||
components: tuple[AmesimMecmas21, ...]
|
components: tuple[AmesimMecmas21, ...]
|
||||||
mode: ConstraintMode = "uninitialized"
|
mode: ConstraintMode = "uninitialized"
|
||||||
|
contact_components: tuple[AmesimLstp00a, ...] = ()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def representative(self) -> AmesimMecmas21:
|
def representative(self) -> AmesimMecmas21:
|
||||||
@@ -361,9 +463,52 @@ class MechanicalStateReducer:
|
|||||||
roots = (find("x", first_port), find("v", first_port))
|
roots = (find("x", first_port), find("v", first_port))
|
||||||
masses_by_roots.setdefault(roots, []).append(component)
|
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(
|
return tuple(
|
||||||
MechanicalConstraintGroup(tuple(components))
|
MechanicalConstraintGroup(
|
||||||
for components in masses_by_roots.values()
|
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, ...]:
|
def _build_state_entries(self) -> tuple[StateEntry, ...]:
|
||||||
@@ -391,26 +536,253 @@ class MechanicalStateReducer:
|
|||||||
def has_state_events(self) -> bool:
|
def has_state_events(self) -> bool:
|
||||||
return any(group.discrete_endstop_components for group in self.groups)
|
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(
|
def absolute_tolerances(
|
||||||
self,
|
self,
|
||||||
default: float,
|
default: float,
|
||||||
*,
|
*,
|
||||||
mechanical: float = 1.0e-12,
|
mechanical: float = 1.0e-12,
|
||||||
|
mode: MechanicalAbsoluteToleranceMode | None = None,
|
||||||
) -> list[float]:
|
) -> 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
|
return list(
|
||||||
endstop position across the much smaller unilateral boundary band while
|
self.absolute_tolerance_plan(
|
||||||
constructing finite-difference Jacobians. Mechanical coordinates need
|
default,
|
||||||
a tighter floor; thermodynamic states retain the caller's tolerance.
|
mechanical=mechanical,
|
||||||
"""
|
mode=mode,
|
||||||
values: list[float] = []
|
).values
|
||||||
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
|
|
||||||
|
|
||||||
def reset_constraint_modes(self) -> None:
|
def reset_constraint_modes(self) -> None:
|
||||||
for group in self.groups:
|
for group in self.groups:
|
||||||
|
|||||||
@@ -15,6 +15,135 @@ DenseState = Callable[[float], list[float]]
|
|||||||
JacobianCallable = Callable[[float, object], object]
|
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)
|
@dataclass(frozen=True)
|
||||||
class StateTransition:
|
class StateTransition:
|
||||||
"""A state reset located inside an accepted integration step."""
|
"""A state reset located inside an accepted integration step."""
|
||||||
@@ -773,6 +902,7 @@ def _integrate_scipy_stepwise(
|
|||||||
state_transition_handler: StateTransitionHandler | None = None,
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
jac_sparsity=None,
|
jac_sparsity=None,
|
||||||
jac: JacobianCallable | None = None,
|
jac: JacobianCallable | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> ODESolution:
|
) -> ODESolution:
|
||||||
"""Initial stepwise integration path for breakpoints and state resets.
|
"""Initial stepwise integration path for breakpoints and state resets.
|
||||||
|
|
||||||
@@ -796,6 +926,18 @@ def _integrate_scipy_stepwise(
|
|||||||
if solver_type is None:
|
if solver_type is None:
|
||||||
raise ValueError(f"Unsupported integration method: {config.method}")
|
raise ValueError(f"Unsupported integration method: {config.method}")
|
||||||
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
|
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)]
|
times = [float(config.t_start)]
|
||||||
states = [[float(value)] for value in initial_state]
|
states = [[float(value)] for value in initial_state]
|
||||||
@@ -886,8 +1028,8 @@ def _integrate_scipy_stepwise(
|
|||||||
"max_step": segment_max_step,
|
"max_step": segment_max_step,
|
||||||
}
|
}
|
||||||
if config.method in {"BDF", "Radau"}:
|
if config.method in {"BDF", "Radau"}:
|
||||||
if implicit_jac is not None:
|
if solver_jac is not None:
|
||||||
solver_options["jac"] = implicit_jac
|
solver_options["jac"] = solver_jac
|
||||||
elif jac_sparsity is not None:
|
elif jac_sparsity is not None:
|
||||||
solver_options["jac_sparsity"] = jac_sparsity
|
solver_options["jac_sparsity"] = jac_sparsity
|
||||||
requested_first_step = (
|
requested_first_step = (
|
||||||
@@ -909,6 +1051,11 @@ def _integrate_scipy_stepwise(
|
|||||||
start_segment = getattr(implicit_jac, "start_segment", None)
|
start_segment = getattr(implicit_jac, "start_segment", None)
|
||||||
if start_segment is not None:
|
if start_segment is not None:
|
||||||
start_segment()
|
start_segment()
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_phase(
|
||||||
|
"solver_initialization",
|
||||||
|
last_accepted_time,
|
||||||
|
)
|
||||||
solver = solver_type(
|
solver = solver_type(
|
||||||
cancellable_rhs,
|
cancellable_rhs,
|
||||||
last_accepted_time,
|
last_accepted_time,
|
||||||
@@ -980,6 +1127,10 @@ def _integrate_scipy_stepwise(
|
|||||||
integration_end - last_accepted_time
|
integration_end - last_accepted_time
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_solver_step(
|
||||||
|
last_accepted_time
|
||||||
|
)
|
||||||
step_message = solver.step()
|
step_message = solver.step()
|
||||||
except IntegrationCancelled:
|
except IntegrationCancelled:
|
||||||
status = "cancelled"
|
status = "cancelled"
|
||||||
@@ -1365,6 +1516,7 @@ def integrate_ode(
|
|||||||
jac_sparsity=None,
|
jac_sparsity=None,
|
||||||
jac: JacobianCallable | None = None,
|
jac: JacobianCallable | None = None,
|
||||||
recoverable_trial_retries: bool = False,
|
recoverable_trial_retries: bool = False,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
):
|
):
|
||||||
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
"""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
|
into the stepwise path so a ``RecoverableTrialStateError`` can rebuild the
|
||||||
solver from its last accepted state. It defaults to false to preserve the
|
solver from its last accepted state. It defaults to false to preserve the
|
||||||
direct ``solve_ivp`` path for ordinary callers.
|
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 (
|
if (
|
||||||
state_transition_handler is not None
|
state_transition_handler is not None
|
||||||
and config.t_stop < config.t_start
|
and config.t_stop < config.t_start
|
||||||
@@ -1406,22 +1584,22 @@ def integrate_ode(
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
if normalized_breakpoints:
|
if normalized_breakpoints:
|
||||||
return _runge_kutta_4_segmented(
|
return _runge_kutta_4_segmented(
|
||||||
rhs,
|
integration_rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
config,
|
config,
|
||||||
t_eval,
|
t_eval,
|
||||||
normalized_breakpoints,
|
normalized_breakpoints,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
accepted_step_callback,
|
integration_accepted_step_callback,
|
||||||
state_transition_handler,
|
state_transition_handler,
|
||||||
)
|
)
|
||||||
return _runge_kutta_4(
|
return _runge_kutta_4(
|
||||||
rhs,
|
integration_rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
config,
|
config,
|
||||||
t_eval,
|
t_eval,
|
||||||
cancel_check,
|
cancel_check,
|
||||||
accepted_step_callback,
|
integration_accepted_step_callback,
|
||||||
state_transition_handler,
|
state_transition_handler,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1432,25 +1610,26 @@ def integrate_ode(
|
|||||||
or recoverable_trial_retries
|
or recoverable_trial_retries
|
||||||
):
|
):
|
||||||
return _integrate_scipy_stepwise(
|
return _integrate_scipy_stepwise(
|
||||||
rhs,
|
integration_rhs,
|
||||||
initial_state,
|
initial_state,
|
||||||
config,
|
config,
|
||||||
t_eval,
|
t_eval,
|
||||||
cancel_check or (lambda: False),
|
cancel_check or (lambda: False),
|
||||||
accepted_step_callback,
|
integration_accepted_step_callback,
|
||||||
normalized_breakpoints,
|
normalized_breakpoints,
|
||||||
state_transition_handler,
|
state_transition_handler,
|
||||||
jac_sparsity,
|
jac_sparsity,
|
||||||
jac,
|
jac,
|
||||||
|
activity_tracker,
|
||||||
)
|
)
|
||||||
|
|
||||||
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
|
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
|
||||||
solve_rhs = rhs
|
solve_rhs = integration_rhs
|
||||||
if implicit_jac is not None:
|
if implicit_jac is not None:
|
||||||
observer = getattr(implicit_jac, "observe", None)
|
observer = getattr(implicit_jac, "observe", None)
|
||||||
if observer is not None:
|
if observer is not None:
|
||||||
def observed_rhs(time, state):
|
def observed_rhs(time, state):
|
||||||
derivative = rhs(time, state)
|
derivative = integration_rhs(time, state)
|
||||||
observer(float(time), state, derivative)
|
observer(float(time), state, derivative)
|
||||||
return derivative
|
return derivative
|
||||||
|
|
||||||
@@ -1459,6 +1638,19 @@ def integrate_ode(
|
|||||||
if start_segment is not None:
|
if start_segment is not None:
|
||||||
start_segment()
|
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 = {
|
solve_options = {
|
||||||
"fun": solve_rhs,
|
"fun": solve_rhs,
|
||||||
"t_span": (config.t_start, config.t_stop),
|
"t_span": (config.t_start, config.t_stop),
|
||||||
@@ -1471,8 +1663,11 @@ def integrate_ode(
|
|||||||
}
|
}
|
||||||
if config.first_step is not None:
|
if config.first_step is not None:
|
||||||
solve_options["first_step"] = config.first_step
|
solve_options["first_step"] = config.first_step
|
||||||
if implicit_jac is not None:
|
if solve_jac is not None:
|
||||||
solve_options["jac"] = implicit_jac
|
solve_options["jac"] = solve_jac
|
||||||
elif jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
elif jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
||||||
solve_options["jac_sparsity"] = jac_sparsity
|
solve_options["jac_sparsity"] = jac_sparsity
|
||||||
return solve_ivp(**solve_options)
|
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,
|
IntegrationCancelled,
|
||||||
ODESolution,
|
ODESolution,
|
||||||
SolveIVPConfig,
|
SolveIVPConfig,
|
||||||
|
SolverActivityTracker,
|
||||||
integrate_ode,
|
integrate_ode,
|
||||||
)
|
)
|
||||||
from app.simulation.solvers.signal import SignalResolver
|
from app.simulation.solvers.signal import SignalResolver
|
||||||
@@ -453,6 +454,7 @@ class GenericFluidSystem:
|
|||||||
self.pneumatic_volume_propagation_count = 0
|
self.pneumatic_volume_propagation_count = 0
|
||||||
self._jacobian_sparsity = None
|
self._jacobian_sparsity = None
|
||||||
self._ode_tangent_provider: ThreePistonTangentProvider | None = None
|
self._ode_tangent_provider: ThreePistonTangentProvider | None = None
|
||||||
|
self._activity_tracker: SolverActivityTracker | None = None
|
||||||
|
|
||||||
def _request_causal_residual_audit(self) -> None:
|
def _request_causal_residual_audit(self) -> None:
|
||||||
"""Make topology or mode boundaries verify the next causal closure."""
|
"""Make topology or mode boundaries verify the next causal closure."""
|
||||||
@@ -957,6 +959,8 @@ class GenericFluidSystem:
|
|||||||
*,
|
*,
|
||||||
record_rhs_outcome: bool = False,
|
record_rhs_outcome: bool = False,
|
||||||
) -> dict[str, dict[str, float]]:
|
) -> 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()
|
transaction = self._thermofluid_transaction_plan.capture()
|
||||||
last_algebraic_diagnostics = self._last_algebraic_diagnostics
|
last_algebraic_diagnostics = self._last_algebraic_diagnostics
|
||||||
last_algebraic_scope = self._last_algebraic_scope
|
last_algebraic_scope = self._last_algebraic_scope
|
||||||
@@ -1156,6 +1160,8 @@ class GenericFluidSystem:
|
|||||||
self,
|
self,
|
||||||
connected_h: dict[str, dict[str, float]],
|
connected_h: dict[str, dict[str, float]],
|
||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
|
if self._activity_tracker is not None:
|
||||||
|
self._activity_tracker.record_phase("state_derivatives")
|
||||||
return self.pneumatic_storage_reducer.coupled_derivatives(
|
return self.pneumatic_storage_reducer.coupled_derivatives(
|
||||||
self.mechanical_state_reducer.state_derivatives(connected_h)
|
self.mechanical_state_reducer.state_derivatives(connected_h)
|
||||||
)
|
)
|
||||||
@@ -1194,7 +1200,32 @@ class GenericFluidSystem:
|
|||||||
sample_step: float,
|
sample_step: float,
|
||||||
progress_callback: SimulationProgressCallback | None = None,
|
progress_callback: SimulationProgressCallback | None = None,
|
||||||
cancel_check: SimulationCancellationCheck | None = None,
|
cancel_check: SimulationCancellationCheck | None = None,
|
||||||
|
activity_tracker: SolverActivityTracker | None = None,
|
||||||
) -> GenericSimulationResult:
|
) -> 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_progress = -1.0
|
||||||
last_reported_phase = ""
|
last_reported_phase = ""
|
||||||
|
|
||||||
@@ -1223,12 +1254,21 @@ class GenericFluidSystem:
|
|||||||
report_progress(0.0, "initializing", force=True)
|
report_progress(0.0, "initializing", force=True)
|
||||||
with performance_span("simulation.sample_initialization"):
|
with performance_span("simulation.sample_initialization"):
|
||||||
integration_config = config
|
integration_config = config
|
||||||
|
mechanical_tolerance_plan = None
|
||||||
if isinstance(config.atol, (int, float)):
|
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(
|
integration_config = replace(
|
||||||
config,
|
config,
|
||||||
atol=self.mechanical_state_reducer.absolute_tolerances(
|
atol=list(mechanical_tolerance_plan.values),
|
||||||
float(config.atol)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
t_eval = simulation_sample_times(config, sample_step)
|
t_eval = simulation_sample_times(config, sample_step)
|
||||||
signal_event_times = self.signal_resolver.event_times(
|
signal_event_times = self.signal_resolver.event_times(
|
||||||
@@ -1321,6 +1361,8 @@ class GenericFluidSystem:
|
|||||||
def evaluate_jacobian_rhs(time, state):
|
def evaluate_jacobian_rhs(time, state):
|
||||||
if cancel_check is not None and cancel_check():
|
if cancel_check is not None and cancel_check():
|
||||||
raise IntegrationCancelled
|
raise IntegrationCancelled
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_rhs(float(time))
|
||||||
return monitored_rhs(
|
return monitored_rhs(
|
||||||
time,
|
time,
|
||||||
[float(value) for value in state],
|
[float(value) for value in state],
|
||||||
@@ -1372,6 +1414,7 @@ class GenericFluidSystem:
|
|||||||
jac_sparsity=jac_sparsity,
|
jac_sparsity=jac_sparsity,
|
||||||
jac=jacobian,
|
jac=jacobian,
|
||||||
recoverable_trial_retries=True,
|
recoverable_trial_retries=True,
|
||||||
|
activity_tracker=activity_tracker,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._ode_tangent_provider = None
|
self._ode_tangent_provider = None
|
||||||
@@ -1388,6 +1431,11 @@ class GenericFluidSystem:
|
|||||||
else max(0.0, last_reported_progress)
|
else max(0.0, last_reported_progress)
|
||||||
)
|
)
|
||||||
report_progress(postprocess_progress, "postprocessing", force=True)
|
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]
|
times = [float(value) for value in solution.t]
|
||||||
if isinstance(solution, ODESolution):
|
if isinstance(solution, ODESolution):
|
||||||
solver_segment_diagnostics = [
|
solver_segment_diagnostics = [
|
||||||
@@ -1601,6 +1649,11 @@ class GenericFluidSystem:
|
|||||||
self._close_current_state(times[time_index])
|
self._close_current_state(times[time_index])
|
||||||
self._append_current_state(series)
|
self._append_current_state(series)
|
||||||
series["time"].append(times[time_index])
|
series["time"].append(times[time_index])
|
||||||
|
if activity_tracker is not None:
|
||||||
|
activity_tracker.record_phase(
|
||||||
|
"postprocessing",
|
||||||
|
times[time_index],
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
run_status = "failed"
|
run_status = "failed"
|
||||||
result_message = str(exc)
|
result_message = str(exc)
|
||||||
@@ -1621,6 +1674,14 @@ class GenericFluidSystem:
|
|||||||
diagnostics = {
|
diagnostics = {
|
||||||
"integration": {
|
"integration": {
|
||||||
"method": integration_config.method,
|
"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,
|
"jacobianSparsity": jacobian_diagnostics,
|
||||||
"jacobian": runtime_jacobian_diagnostics,
|
"jacobian": runtime_jacobian_diagnostics,
|
||||||
"segmentCount": len(solver_segment_diagnostics),
|
"segmentCount": len(solver_segment_diagnostics),
|
||||||
@@ -1730,6 +1791,19 @@ class GenericFluidSystem:
|
|||||||
for variable in self.network.result_variable_metadata()
|
for variable in self.network.result_variable_metadata()
|
||||||
if variable.key in series
|
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(
|
report_progress(
|
||||||
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
||||||
"complete" if run_status == "completed" else run_status,
|
"complete" if run_status == "completed" else run_status,
|
||||||
@@ -1740,9 +1814,7 @@ class GenericFluidSystem:
|
|||||||
status=run_status,
|
status=run_status,
|
||||||
message=result_message,
|
message=result_message,
|
||||||
simulated_until=(
|
simulated_until=(
|
||||||
float(series["time"][-1])
|
final_simulated_time
|
||||||
if series["time"]
|
|
||||||
else float(config.t_start)
|
|
||||||
),
|
),
|
||||||
requested_stop_time=float(config.t_stop),
|
requested_stop_time=float(config.t_stop),
|
||||||
variables=variables,
|
variables=variables,
|
||||||
|
|||||||
+102
-43
@@ -37,7 +37,9 @@
|
|||||||
- `2.100000 s`:完成积分并进入后处理
|
- `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 环境说明
|
### 2.2 环境说明
|
||||||
|
|
||||||
@@ -135,7 +137,8 @@
|
|||||||
- [ ] `0.68–0.71 s`:历史慢区。
|
- [ ] `0.68–0.71 s`:历史慢区。
|
||||||
- [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。
|
- [ ] `0.79–0.81 s`:原始模型终点及信号事件附近。
|
||||||
- [ ] `2.00–2.10 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` 能完成同一时域,则更小
|
||||||
`maxStep` 不得反而出现不可恢复数值失败。更小上限可以更慢;若仅因工作量增加超过预算,必须归类为
|
`maxStep` 不得反而出现不可恢复数值失败。更小上限可以更慢;若仅因工作量增加超过预算,必须归类为
|
||||||
`budget_limited` 并证明仍持续推进,不能记为 solver failure 或借此选择一个“幸运步长”。
|
`budget_limited` 并证明仍持续推进,不能记为 solver failure 或借此选择一个“幸运步长”。
|
||||||
- [ ] 首先对账 JSON、浏览器生成的 System XML、服务请求和 worker 最终生效参数;保存输入哈希,禁止用 runner 的内存覆盖掩盖浏览器路径问题。
|
- [x] 已对账 JSON、浏览器生成 System XML 的参数直传代码、服务请求和 worker 最终回显:`BDF / 0.2 s / 0.01 s / 0.001 s` 没有被前端或后端改写;JSON/XML 权威哈希及配对契约测试通过。
|
||||||
- [ ] 浏览器当前工程验收明确为失败:`0.2 s / 0.001 s` 在 `t≈0.0489 s` 被判定计算超时;离线 production runner 的完成记录只作对照,不能写成浏览器正常完成。必须用同一 JSON 复现并分类为内部慢步、求解器真停滞、后处理/传输或服务假超时。
|
- [x] 已复现并分类浏览器 `t≈0.0489 s` 计算超时:同参流式 API 能完整到达 `0.2 s`,但修复前浏览器曾把一次仍有 CPU 活动的约 70 s 慢步误判为 `SOLVER_STALLED` 并主动取消。该结果是“服务假超时 + 后端真实慢区”,不是该状态的不可恢复数值失败或网络断流。
|
||||||
- [ ] 第一阶段在其余参数不变时串行运行 `tStop={0.2,1} s × maxStep={0.001,0.002,0.005,0.01,0.02} s` 的短时二维矩阵;最终签收要求所有单元到达终点,不得出现 NaN/Inf、不可恢复数值错误或无解释回退。定位阶段允许记录有持续进展证据的 `budget_limited`,但它既不算数值失败,也不算通过。
|
- [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` 严格公共前缀五档均通过。
|
||||||
- [ ] 短时二维矩阵通过后,再按相同契约依次延长到 `2 s → 5 s → 10 s`;每一档先运行权威 `0.001 s`,再运行代表性的 `0.005/0.02 s`,最后补齐其余步长。每一档先确认同 `maxStep` 公共前缀,再进入下一档,不因较长 `tStop` 改变已覆盖区间的成败结论。
|
- [x] 已将同一五档矩阵延长到 `2 s`:5 个单元全部到达终点、0 次恢复重试,`1→2 s` 的严格公共前缀五档逐位一致,机械事件顺序一致且时刻跨度不超过 `9.58 µs`。
|
||||||
- [ ] 跨 `maxStep` 不要求逐位相同;按积分状态、派生量、关键压力/流量、守恒量、事件和离散模式分别验收,近零派生加速度不得使用统一相对误差误判真实积分状态。
|
- [ ] 继续延长到 `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/热流体闭合与恢复轨迹;性能悬崖必须能定位到具体阶段和组件。
|
- [ ] 每个 `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`;浏览器超时始终属于工程路径未通过,但在证据不足时不得冒充数值失败。
|
- [ ] 将终止结果明确分类为 `numerical_failure`、`service_timeout_worker_active`、`active_slow_trial`、`true_stall` 或 `budget_limited`;浏览器超时始终属于工程路径未通过,但在证据不足时不得冒充数值失败。
|
||||||
- [ ] 任一会改变数值路径、事件语义、容差或默认求解策略的修复,必须先形成“复现证据 → 首个异常阶段 → 根因假设 → 最小方案 → A/B 判据 → 回退方式”,提交审阅后再实施;每轮只修改一个概念并先复跑原失败单元。
|
- [ ] 任一会改变数值路径、事件语义、容差或默认求解策略的修复,必须先形成“复现证据 → 首个异常阶段 → 根因假设 → 最小方案 → 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`,也不是要求所有步长得到位级相同轨迹,而是让合理工程区间内的
|
||||||
`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. 优化任务总览
|
## 4. 优化任务总览
|
||||||
|
|
||||||
优先级定义:`P0` 为基线或正确性前置,`P1` 为主要性能收益,`P2` 为第二阶段,`P3` 为战略性或条件性工作。
|
优先级定义:`P0` 为基线或正确性前置,`P1` 为主要性能收益,`P2` 为第二阶段,`P3` 为战略性或条件性工作。
|
||||||
|
|
||||||
| ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 |
|
| ID | 优先级 | 任务 | 当前状态 | 难度 | 预期价值 | 主要依赖 |
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
| OPT-00 | P0 | 固化复现、环境和回归基线 | 重新打开(离线基线完成;浏览器路径失败待定位) | 中 | 很高 | 无 |
|
| OPT-00 | P0 | 固化复现、环境和回归基线 | 已完成(本地 P0 基础闭环;远端 CI 运营证据待补) | 中 | 很高 | 无 |
|
||||||
| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 基本完成(主要矛盾闭环) | 中高 | 中高 | OPT-00 |
|
| OPT-01 | P1 | 完成因果代数内核与坐标消元 | 基本完成(主要矛盾闭环) | 中高 | 中高 | OPT-00 |
|
||||||
| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 部分实现(参考 IR) | 很高 | 很高 | OPT-01 |
|
| OPT-02 | P1 | 建立扁平数值 IR 和数组执行内核 | 部分实现(参考 IR) | 很高 | 很高 | OPT-01 |
|
||||||
| OPT-03 | P1 | 稀疏 Jacobian 数值层与解析/半解析演进 | 部分实现 | 很高 | 很高 | OPT-00;解析链可与 OPT-02 分阶段 |
|
| OPT-03 | P1 | 稀疏 Jacobian 数值层与解析/半解析演进 | 部分实现 | 很高 | 很高 | OPT-00;解析链可与 OPT-02 分阶段 |
|
||||||
| OPT-04 | P1 | stream 拓扑传播与物性成组复用 | 部分实现 | 中高 | 中高 | OPT-00 |
|
| 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-06 | P2 | 事件检测与 dense output 按需化 | 部分实现 | 中 | 中 | OPT-00 |
|
||||||
| OPT-07 | P2 | 输出、后处理和传输内存优化 | 未开始 | 中 | 中高(长仿真) | OPT-00 |
|
| OPT-07 | P2 | 输出、后处理和传输内存优化 | 未开始 | 中 | 中高(长仿真) | OPT-00 |
|
||||||
| OPT-08 | P0/P2 | 进度、取消和服务并发鲁棒性 | 进行中(浏览器 `0.0489 s` 失败待定位) | 中 | 很高 | OPT-00、OPT-05 P0 门 |
|
| 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-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-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. 详细任务
|
## 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] 将无数值的完整输出形状契约与物理状态 golden 分开;完整 API 序列化契约若需逐字段稳定性,后续另行定义。
|
||||||
- [x] 建立短 CI、夜间 `0.81/2.10 s`、定期递进至 `10 s` 的三层 workflow;远端首次执行待提交后确认。
|
- [x] 建立短 CI、夜间 `0.81/2.10 s`、定期递进至 `10 s` 的三层 workflow;远端首次执行待提交后确认。
|
||||||
- [x] 保存带环境、仓库、输入、运行统计和验收结果的机器可读 JSON 报告。
|
- [x] 保存带环境、仓库、输入、运行统计和验收结果的机器可读 JSON 报告。
|
||||||
- [ ] 以权威 JSON 经浏览器生成 XML 并走流式 API 的真实路径完成 `0.2 s / 0.001 s`,与 production worker 对账输入哈希、生效参数、事件、物理量和最终结果;定位并消除 `t≈0.0489 s` 的计算超时或假超时。
|
- [x] 以权威 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] 完成第 3.4 节的 `0.2/1 s × 五档 maxStep` P0 单元可解性门及同 `maxStep` 时域延长不变量;跨 `maxStep` 的自动分层数值契约继续归 OPT-05。
|
||||||
|
|
||||||
**验收条件**:
|
**验收条件**:
|
||||||
|
|
||||||
- [x] 干净环境可按发布锁一条安装命令复现:全新空 venv 使用 22 个锁定 wheel 与 SHA-256 完成安装,`pip check`、锁定环境契约和最终 quick workflow 同口径测试通过。
|
- [x] 干净环境可按发布锁一条安装命令复现:全新空 venv 使用 22 个锁定 wheel 与 SHA-256 完成安装,`pip check`、锁定环境契约和最终 quick workflow 同口径测试通过。
|
||||||
- [x] 正式 production 环境严格串行 3 次完成 `0.81/2.10 s`;两档检查点、状态、事件、输出契约和除计时外的诊断逐值一致,无非有限值或非预期回退。
|
- [x] 正式 production 环境严格串行 3 次完成 `0.81/2.10 s`;两档检查点、状态、事件、输出契约和除计时外的诊断逐值一致,无非有限值或非预期回退。
|
||||||
- [x] 新主目标 `0.2 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 网络一次传播,只对真正的强连通块迭代;同一状态反算的物性量成组计算和复用。
|
||||||
|
|
||||||
**当前状态**: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 | 待填 |
|
| 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 | 待填 |
|
| `2.10 s` 压力闭合 | 57,601 | 待填 |
|
||||||
| 物性调用 / 缓存命中率 | 待测 | 待填 |
|
| 物性调用 / 缓存命中率 | 待测 | 待填 |
|
||||||
|
|
||||||
@@ -550,7 +581,7 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
|
|||||||
|
|
||||||
**目标**:首先保证在合理 `maxStep` 工程区间内,步长上限只影响可解释的误差和成本,而不决定仿真能否完成;随后再减少量纲差异造成的不必要小步和 Jacobian 重建,同时维持事件与守恒精度。
|
**目标**:首先保证在合理 `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 重建原因。
|
- [ ] 统计限制步长的状态分量、误差拒步和 Jacobian 重建原因。
|
||||||
- [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。
|
- [ ] 对事件前后、接触临界区和稳态区分别评估步长上限策略。
|
||||||
- [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。
|
- [ ] 建立严格/标准/快速配置,但默认配置必须有明确精度契约。
|
||||||
- [ ] 用完全相同的 JSON 生成请求,对浏览器流式路径与 production worker 做同参 A/B,逐层核对 requested/effective `tStop/sampleStep/maxStep/method`。
|
- [x] 用完全相同的 JSON 生成请求,对浏览器流式路径、真实 API 与 production worker 做同参 A/B,逐层核对 requested/effective `tStop/sampleStep/maxStep/method`。
|
||||||
- [ ] 围绕 `t=0.04–0.05 s` 按模拟区间记录 BDF 阶次、实际 `h_abs`、接受/拒绝步、重启、Jacobian/RHS/闭合耗时及最坏组件,定位 `0.0489 s` 的首次性能悬崖。
|
- [x] 围绕 `t=0.04–0.05 s` 记录接受步、Jacobian/RHS/闭合工作量并定位首次性能悬崖到 8 个 `LSTP00A` 高刚度接触的微步簇。
|
||||||
- [ ] 在改变算法前提交定位报告与候选方案供审阅,明确证据、影响面、正确性风险、预期计数变化和回退方式;获准后只实施一个概念,并以原失败单元及同一行/列相邻配置做 A/B。
|
- [ ] 补齐逐 `0.01 s` 的 BDF order、全程 `h_abs`、拒步与 SciPy 内部有限差分 Jacobian 阶段时间线;现有实时 `jacobianEvaluationCount` 只覆盖显式 callable Jacobian。
|
||||||
- [ ] 审计失败试探的事务恢复、缓存、端口和离散模式,证明失败点不会污染下一条缩步路径。
|
- [x] 在改变算法前提交定位报告与候选方案供审阅,明确证据、影响面、正确性风险、预期计数变化和回退方式;获准后逐概念 A/B,拒绝了改变轨迹或收益门未过的候选。
|
||||||
|
- [x] 审计失败试探的事务恢复、缓存、端口和离散模式;故障注入证明失败后可重放,失败诊断保留且不会污染下一条缩步路径。
|
||||||
- [ ] 只在证据支持时对事件、接触、流向或闭合边界使用局部步长上限、有界缩步或模式感知策略;禁止靠全程硬编码某个“幸运” `maxStep` 收口。
|
- [ ] 只在证据支持时对事件、接触、流向或闭合边界使用局部步长上限、有界缩步或模式感知策略;禁止靠全程硬编码某个“幸运” `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] 对可恢复的热流体闭合失败使用积分器实际试探步 `h_abs` 对半回退;最多 16 次且不低于 64 ULP,恢复步仅设置 `first_step`,首次接受后恢复分段 `maxStep` 上限并记录 attempted/next step。
|
||||||
- [x] 为 eventless Generic 显式启用 `recoverable_trial_retries`,使没有状态事件、断点或取消回调的通用模型也能选择 stepwise 恢复;该参数默认关闭,避免改变其他调用者的直接 `solve_ivp` 语义。
|
- [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 门:短时二维矩阵满足时域延长与步长细化不变量,事件/模式/守恒满足分类契约,任何性能悬崖都有可复现的阶段与组件归因。
|
- [ ] 满足第 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 按需化
|
### OPT-06 事件检测与 dense output 按需化
|
||||||
|
|
||||||
**目标**:避免在绝大多数没有事件候选、也不跨输出采样点的接受步上创建 dense output。
|
**目标**:避免在绝大多数没有事件候选、也不跨输出采样点的接受步上创建 dense output。
|
||||||
@@ -599,7 +649,7 @@ PYTHONPATH=. .venv/bin/python -m app.simulation.benchmark_regression \
|
|||||||
|
|
||||||
**目标**:在长仿真中控制结果生成、JSON 编码、前端复制和峰值内存。
|
**目标**:在长仿真中控制结果生成、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 构建和后处理内加入有界取消检查。
|
- [ ] 在代数闭合、stream 迭代、Jacobian 构建和后处理内加入有界取消检查。
|
||||||
- [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。
|
- [ ] 限制并发仿真 worker、队列长度和单任务 CPU/内存预算。
|
||||||
- [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。
|
- [ ] 超时报告最后活动阶段、模拟时刻、步长和关键计数,而非只返回通用错误。
|
||||||
- [ ] 添加故意慢 RHS、死循环防护、客户端断连和多任务竞争测试。
|
- [ ] 添加故意慢 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 结果。
|
- [ ] 验证客户端流断开、浏览器本地 watchdog 和显式取消的语义不同;客户端误判或断连不得在无用户授权时静默丢失仍健康运行的 worker 结果。
|
||||||
- [x] 热流体失败记录 RHS 时刻、最近迭代尾部、最大增量/尺度/容差、最差端口及带符号差值,并保留求解器逐次恢复的 attempted/next step 与原因。
|
- [x] 热流体失败记录 RHS 时刻、最近迭代尾部、最大增量/尺度/容差、最差端口及带符号差值,并保留求解器逐次恢复的 attempted/next step 与原因。
|
||||||
- [x] 矩阵报告分别记录外层 `soft_timeout` 和 worker 的合作 `cancelled`,避免把预算取消误记为求解器数值失败。
|
- [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 长时验证与模式覆盖
|
### OPT-09 建立 10 s 长时验证与模式覆盖
|
||||||
|
|
||||||
**目标**:用权威 JSON 的 `BDF / tStop=10 s / sampleStep=0.01 s / maxStep=0.001 s` 完整实测,替代“短仿真或较大 `maxStep` 可以外推到最终工程场景”的假设。
|
**目标**:用权威 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` 尚在运行;完成结果及其后追加的通用接线复验见下方收口记录。
|
`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 | —(旧版未记录) | 首次延长门通过 |
|
| 1 s | 0.05 s | solver-only / 完成 | 182.111 s | —(旧版未记录) | 首次延长门通过 |
|
||||||
| 2 s | 0.01 s | production / 完成 | 324.727 s | 8 | 最大热流体迭代 19 |
|
| 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` 重新运行。
|
- [ ] 首次长基线不得因总墙钟较长而提前当作性能失败;只有 worker、CPU 和内部活动心跳均停止并满足真停滞条件时才有界终止。若发现致命正确性问题,只做使基线可完成的最小修复,然后从 `t=0` 重新运行。
|
||||||
- [ ] 在正式锁定环境运行未优化基线 `10 s`,设置心跳、资源上限和可恢复日志。
|
- [ ] 在正式锁定环境运行当前优化版本基线 `10 s`,设置心跳、资源上限和可恢复日志;用户已明确授权在首次 10 s 前先解决 70 s 慢区。
|
||||||
- [ ] 保存事件、模式、步长、拒步、Jacobian、闭合和内存随模拟时间的时间线。
|
- [ ] 保存事件、模式、步长、拒步、Jacobian、闭合和内存随模拟时间的时间线。
|
||||||
- [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。
|
- [ ] 为长跑设置阶段性检查点,支持定位首次偏差而非只比较终点。
|
||||||
- [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。
|
- [ ] 将每项 P1 优化分别加入 `10 s` A/B,不把多个改动混成一个结果。
|
||||||
- [ ] 根据首次基线制定合理的 CI 频率和资源门槛。
|
- [ ] 根据首次基线制定合理的 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` 报告不得用于跳过该顺序。
|
- [ ] 首次 `10 s / maxStep=0.001 s` 完整报告生成后,才根据各阶段墙钟与内部计数决定性能优化目标;旧 `0.02 s` 报告不得用于跳过该顺序。
|
||||||
|
|
||||||
**验收条件**:
|
**验收条件**:
|
||||||
@@ -737,13 +792,13 @@ PNL00R stream 语义、单次 RHS 事务回滚和基于实际试探步的恢复
|
|||||||
| 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
| 本文复杂 XML `2.10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
||||||
| 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
| 本文复杂 XML `10 s` | 必测 | 必测 | 必测 | 必测 | 必测 | 必测 |
|
||||||
| 主目标 `test-mql-8` `0.2 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` | 必测 | 必测 | 必测 | 必测 | 必测(含内部活动心跳) | 可选 |
|
| 权威 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` | 必测 | 必测 | 必测 | 必测 | 必测(完整时间线) | 必测 |
|
| 权威 JSON `10 s / maxStep=0.001 s` | 必测 | 必测 | 必测 | 必测 | 必测(完整时间线) | 必测 |
|
||||||
|
|
||||||
2026-08-17 的 `maxStep=0.01/0.02/0.05/0.10 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_thermofluid_closure_plan.py`
|
||||||
- `tests/test_max_step_matrix.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. 单项更新模板
|
## 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-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 | 工作树基于 `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 | 同一权威 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. 相关文档
|
## 9. 相关文档
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,14 @@
|
|||||||
- 最终 quick workflow 同口径 179 项通过(2 项预期跳过),完整后端 849 项通过(3 项预期跳过),`git diff --check` 与残留进程检查进入最终收尾。
|
- 最终 quick workflow 同口径 179 项通过(2 项预期跳过),完整后端 849 项通过(3 项预期跳过),`git diff --check` 与残留进程检查进入最终收尾。
|
||||||
- OPT-00 已完成当时工作树的本地验收。仍存在的问题:批准报告来自 `684d287` 的脏工作树;后续求解器提交合入后尚未重新运行真实 production 基线和全量回归,远端 workflow 也待提交后首次托管验证。
|
- 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
|
## 23:21
|
||||||
|
|
||||||
- 完成当前工作区快照存档并推送至远端 `model-development`,提交为 `a8c7338`;本次纳管 32 个文件,相关 68 项回归测试通过(3 项按条件跳过),差异检查无异常。
|
- 完成当前工作区快照存档并推送至远端 `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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
+217
-16
@@ -109,8 +109,12 @@ import {
|
|||||||
lmechn1RightPortCount,
|
lmechn1RightPortCount,
|
||||||
} from "./componentSymbols/mechanical";
|
} from "./componentSymbols/mechanical";
|
||||||
import {
|
import {
|
||||||
|
SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
|
||||||
|
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS,
|
||||||
|
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES,
|
||||||
|
SIMULATION_SOLVER_STALL_TIMEOUT_MS,
|
||||||
|
solverActivityStallTimeoutMessage,
|
||||||
solverStallTimeoutMessage,
|
solverStallTimeoutMessage,
|
||||||
solverStallTimeoutReached,
|
|
||||||
} from "./simulationTimeout";
|
} from "./simulationTimeout";
|
||||||
import {
|
import {
|
||||||
CONTACT_AWARE_EDGE_TYPE,
|
CONTACT_AWARE_EDGE_TYPE,
|
||||||
@@ -1053,6 +1057,37 @@ type SimulationStreamIssue = {
|
|||||||
message?: string;
|
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 SimulationCancelReason = "user" | "stalled";
|
||||||
|
|
||||||
type SimulationTaskResponse = {
|
type SimulationTaskResponse = {
|
||||||
@@ -1064,7 +1099,7 @@ type SimulationTaskResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type SimulationStreamEvent =
|
type SimulationStreamEvent =
|
||||||
| {
|
| ({
|
||||||
event: "progress";
|
event: "progress";
|
||||||
progress: number;
|
progress: number;
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -1072,7 +1107,7 @@ type SimulationStreamEvent =
|
|||||||
heartbeat?: boolean;
|
heartbeat?: boolean;
|
||||||
simulatedTime?: number;
|
simulatedTime?: number;
|
||||||
totalTime?: number;
|
totalTime?: number;
|
||||||
}
|
} & SimulationActivityTelemetry)
|
||||||
| {
|
| {
|
||||||
event: "result";
|
event: "result";
|
||||||
progress: number;
|
progress: number;
|
||||||
@@ -1092,6 +1127,137 @@ type SimulationStreamEvent =
|
|||||||
simulatedTime?: number;
|
simulatedTime?: number;
|
||||||
totalTime?: 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 SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
|
||||||
const simulationConfigLabels: Record<SimulationNumericKey, string> = {
|
const simulationConfigLabels: Record<SimulationNumericKey, string> = {
|
||||||
t_start: "起始时间",
|
t_start: "起始时间",
|
||||||
@@ -7355,10 +7521,11 @@ function FlowWorkbench() {
|
|||||||
setSimulationStopPending(false);
|
setSimulationStopPending(false);
|
||||||
let lastLoggedPhase = "";
|
let lastLoggedPhase = "";
|
||||||
let lastLoggedBucket = -1;
|
let lastLoggedBucket = -1;
|
||||||
|
let lastWatchdogStatus: SimulationActivityWatchdogStatus = "progressing";
|
||||||
try {
|
try {
|
||||||
const result = await streamSystemSimulation(
|
const result = await streamSystemSimulation(
|
||||||
xml,
|
xml,
|
||||||
(event) => {
|
(event, watchdog) => {
|
||||||
if (event.event !== "progress") {
|
if (event.event !== "progress") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -7373,14 +7540,38 @@ function FlowWorkbench() {
|
|||||||
"initialization",
|
"initialization",
|
||||||
"initializing",
|
"initializing",
|
||||||
].includes(event.phase);
|
].includes(event.phase);
|
||||||
|
const progressMessage =
|
||||||
|
watchdog?.status === "slow-active"
|
||||||
|
? `正在处理耗时积分步,内部计算仍活跃${
|
||||||
|
event.activityKind ? `(${event.activityKind})` : ""
|
||||||
|
},可手动停止`
|
||||||
|
: watchdog?.status === "slow-telemetry-unavailable"
|
||||||
|
? "积分步耗时较长,后端未提供活动遥测;任务保持运行,可手动停止"
|
||||||
|
: event.message;
|
||||||
setSimulationProgress((current) => ({
|
setSimulationProgress((current) => ({
|
||||||
...current,
|
...current,
|
||||||
state: "running",
|
state: "running",
|
||||||
percent: normalizedProgress.percent,
|
percent: normalizedProgress.percent,
|
||||||
message: event.message,
|
message: progressMessage,
|
||||||
simulatedTime: normalizedProgress.simulatedTime,
|
simulatedTime: normalizedProgress.simulatedTime,
|
||||||
totalTime: normalizedProgress.totalTime,
|
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);
|
const progressBucket = Math.floor(normalizedProgress.percent / 10);
|
||||||
if (event.phase !== lastLoggedPhase || progressBucket !== lastLoggedBucket) {
|
if (event.phase !== lastLoggedPhase || progressBucket !== lastLoggedBucket) {
|
||||||
const progressDetail = reportsSimulationTime
|
const progressDetail = reportsSimulationTime
|
||||||
@@ -7404,7 +7595,9 @@ function FlowWorkbench() {
|
|||||||
appendConsoleEntry(
|
appendConsoleEntry(
|
||||||
"error",
|
"error",
|
||||||
error.code === "SOLVER_STALLED"
|
error.code === "SOLVER_STALLED"
|
||||||
? solverStallTimeoutMessage("recovering")
|
? error.message.includes("内部活动")
|
||||||
|
? solverActivityStallTimeoutMessage("recovering")
|
||||||
|
: solverStallTimeoutMessage("recovering")
|
||||||
: "超过 30 秒未收到后端数据,正在终止任务并恢复部分结果",
|
: "超过 30 秒未收到后端数据,正在终止任务并恢复部分结果",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -10882,7 +11075,10 @@ async function readSimulationStreamChunk(
|
|||||||
|
|
||||||
async function streamSystemSimulation(
|
async function streamSystemSimulation(
|
||||||
xml: string,
|
xml: string,
|
||||||
onEvent: (event: SimulationStreamEvent) => void,
|
onEvent: (
|
||||||
|
event: SimulationStreamEvent,
|
||||||
|
watchdog?: SimulationActivityWatchdogObservation,
|
||||||
|
) => void,
|
||||||
simulationId: string,
|
simulationId: string,
|
||||||
): Promise<SimulationResult> {
|
): Promise<SimulationResult> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -10920,7 +11116,7 @@ async function streamSystemSimulation(
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let result: SimulationResult | null = null;
|
let result: SimulationResult | null = null;
|
||||||
let lastSolverProgressAt = Date.now();
|
let activityWatchdog = createSimulationActivityWatchdog();
|
||||||
|
|
||||||
const consumeLine = (line: string) => {
|
const consumeLine = (line: string) => {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
@@ -10936,23 +11132,28 @@ async function streamSystemSimulation(
|
|||||||
throw new SimulationStreamError("仿真服务返回了无法识别的进度消息");
|
throw new SimulationStreamError("仿真服务返回了无法识别的进度消息");
|
||||||
}
|
}
|
||||||
const event = parsed as SimulationStreamEvent;
|
const event = parsed as SimulationStreamEvent;
|
||||||
|
let watchdogObservation: SimulationActivityWatchdogObservation | undefined;
|
||||||
if (event.event === "progress") {
|
if (event.event === "progress") {
|
||||||
if (
|
const watched = observeSimulationActivityWatchdog(
|
||||||
event.heartbeat === true &&
|
activityWatchdog,
|
||||||
solverStallTimeoutReached(lastSolverProgressAt)
|
event,
|
||||||
) {
|
);
|
||||||
|
activityWatchdog = watched.state;
|
||||||
|
if (isSimulationSolverActivityPhase(event.phase)) {
|
||||||
|
watchdogObservation = watched.observation;
|
||||||
|
if (watchdogObservation.status === "stalled") {
|
||||||
throw new SimulationStreamError(
|
throw new SimulationStreamError(
|
||||||
solverStallTimeoutMessage("detected"),
|
watchdogObservation.activityTelemetryAvailable
|
||||||
|
? solverActivityStallTimeoutMessage("detected")
|
||||||
|
: solverStallTimeoutMessage("detected"),
|
||||||
[],
|
[],
|
||||||
undefined,
|
undefined,
|
||||||
"SOLVER_STALLED",
|
"SOLVER_STALLED",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (event.heartbeat !== true) {
|
|
||||||
lastSolverProgressAt = Date.now();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onEvent(event);
|
onEvent(event, watchdogObservation);
|
||||||
if (event.event === "result") {
|
if (event.event === "result") {
|
||||||
result = event.result;
|
result = event.result;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
export const SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES = 15;
|
export const SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES = 15;
|
||||||
export const SIMULATION_SOLVER_STALL_TIMEOUT_MS =
|
export const SIMULATION_SOLVER_STALL_TIMEOUT_MS =
|
||||||
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES * 60_000;
|
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(
|
export function solverStallTimeoutReached(
|
||||||
lastSolverProgressAt: number,
|
lastSolverProgressAt: number,
|
||||||
@@ -17,3 +20,12 @@ export function solverStallTimeoutMessage(
|
|||||||
? `${prefix},正在终止任务并恢复部分结果`
|
? `${prefix},正在终止任务并恢复部分结果`
|
||||||
: `${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 { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
|
||||||
|
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS,
|
||||||
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES,
|
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES,
|
||||||
SIMULATION_SOLVER_STALL_TIMEOUT_MS,
|
SIMULATION_SOLVER_STALL_TIMEOUT_MS,
|
||||||
|
solverActivityStallTimeoutMessage,
|
||||||
solverStallTimeoutMessage,
|
solverStallTimeoutMessage,
|
||||||
solverStallTimeoutReached,
|
solverStallTimeoutReached,
|
||||||
} from "../../src/simulationTimeout";
|
} from "../../src/simulationTimeout";
|
||||||
@@ -19,3 +22,11 @@ test("solver stall messages use the configured duration", () => {
|
|||||||
expect(solverStallTimeoutMessage("recovering")).toContain("连续 15 分钟");
|
expect(solverStallTimeoutMessage("recovering")).toContain("连续 15 分钟");
|
||||||
expect(solverStallTimeoutMessage("recovering")).toContain("恢复部分结果");
|
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": {
|
"environment": {
|
||||||
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
||||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
||||||
|
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
|
||||||
|
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
|
||||||
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
||||||
|
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
|
||||||
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
||||||
"SIMULATIONAPP_PROPERTY_CACHE": "on"
|
"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": {
|
"environment": {
|
||||||
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
||||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
||||||
|
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
|
||||||
|
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
|
||||||
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
||||||
|
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
|
||||||
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
||||||
"SIMULATIONAPP_PROPERTY_CACHE": "on"
|
"SIMULATIONAPP_PROPERTY_CACHE": "on"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.main import (
|
|||||||
compile_system_xml_network,
|
compile_system_xml_network,
|
||||||
run_system_xml_simulation,
|
run_system_xml_simulation,
|
||||||
)
|
)
|
||||||
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
from app.simulation.systems.generic import GenericFluidSystem
|
from app.simulation.systems.generic import GenericFluidSystem
|
||||||
from app.system_xml import validate_system_xml_document
|
from app.system_xml import validate_system_xml_document
|
||||||
from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port
|
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(tolerances, [1.0e-12, 1.0e-12])
|
||||||
self.assertEqual(len(tolerances), len(system.initial_state_vector()))
|
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:
|
def test_sparse_reactflow_mecmas_parameters_use_registered_defaults(self) -> None:
|
||||||
project = mecmas_project(1.0, 1.0)
|
project = mecmas_project(1.0, 1.0)
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
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.core.medium import IdealGasMedium
|
||||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
|
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):
|
class AmesimPnl0001ComponentTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.medium = IdealGasMedium()
|
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)
|
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:
|
def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None:
|
||||||
pipe = AmesimPnl0001("pnl_1", self.medium)
|
pipe = AmesimPnl0001("pnl_1", self.medium)
|
||||||
pipe.port_1.p = 101000.0
|
pipe.port_1.p = 101000.0
|
||||||
|
|||||||
@@ -672,7 +672,10 @@ class ProgressiveSuiteTests(unittest.TestCase):
|
|||||||
[{
|
[{
|
||||||
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
|
||||||
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
|
||||||
|
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
|
||||||
|
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
|
||||||
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
"SIMULATION_CAUSAL_FAST_PATH": "1",
|
||||||
|
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
|
||||||
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
|
||||||
"SIMULATIONAPP_PROPERTY_CACHE": "on",
|
"SIMULATIONAPP_PROPERTY_CACHE": "on",
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.simulation.solvers.causal_ir import (
|
|||||||
from app.simulation.systems.generic import GenericFluidSystem
|
from app.simulation.systems.generic import GenericFluidSystem
|
||||||
from app.system_xml import validate_system_xml_document
|
from app.system_xml import validate_system_xml_document
|
||||||
from tests.test_amesim_mechanical_xml import zero_force_mass_project
|
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_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
|
||||||
from tests.test_generic_system_xml_simulation import chain_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")
|
TARGET_XML = Path("tests/data/test-mql-8.xml")
|
||||||
|
|
||||||
|
|
||||||
|
class _SyntheticDirectReaderError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _system(project) -> GenericFluidSystem:
|
def _system(project) -> GenericFluidSystem:
|
||||||
return GenericFluidSystem(compile_reactflow_network(project))
|
return GenericFluidSystem(compile_reactflow_network(project))
|
||||||
|
|
||||||
|
|
||||||
class CausalNumericIRTests(unittest.TestCase):
|
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:
|
def test_structure_signature_is_stable_and_excludes_bindings(self) -> None:
|
||||||
first = compile_causal_numeric_ir(
|
first = compile_causal_numeric_ir(
|
||||||
_system(zero_force_mass_project()).pressure_flow_solver
|
_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.operations), 76)
|
||||||
self.assertEqual(len(pressure_stage.evaluations), 40)
|
self.assertEqual(len(pressure_stage.evaluations), 40)
|
||||||
self.assertTrue(
|
self.assertEqual(
|
||||||
all(
|
sum(
|
||||||
|
evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
|
||||||
|
for evaluation in pressure_stage.evaluations
|
||||||
|
),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sum(
|
||||||
evaluation.opcode
|
evaluation.opcode
|
||||||
is CausalIROpcode.EFFORT_COMPONENT_RESIDUAL
|
is CausalIROpcode.EFFORT_COMPONENT_RESIDUAL
|
||||||
for evaluation in pressure_stage.evaluations
|
for evaluation in pressure_stage.evaluations
|
||||||
|
),
|
||||||
|
20,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.system.pressure_flow_solver.causal_execution_diagnostics()[
|
||||||
|
"directEffortAnchorCount"
|
||||||
|
],
|
||||||
|
20,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[len(stage.target_slots) for stage in program.flow_stages],
|
[len(stage.target_slots) for stage in program.flow_stages],
|
||||||
[114, 134, 49, 33, 5, 1],
|
[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:
|
def test_target_reference_execution_matches_all_compatibility_slots(self) -> None:
|
||||||
assert self.compilation.ir is not None
|
assert self.compilation.ir is not None
|
||||||
solver = self.system.pressure_flow_solver
|
solver = self.system.pressure_flow_solver
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import math
|
import math
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import types
|
import types
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -8,6 +9,7 @@ from app.simulation.core.errors import RecoverableTrialStateError
|
|||||||
from app.simulation.solvers.solver import (
|
from app.simulation.solvers.solver import (
|
||||||
IntegrationCancelled,
|
IntegrationCancelled,
|
||||||
SolveIVPConfig,
|
SolveIVPConfig,
|
||||||
|
SolverActivityTracker,
|
||||||
StateTransition,
|
StateTransition,
|
||||||
integrate_ode,
|
integrate_ode,
|
||||||
)
|
)
|
||||||
@@ -44,6 +46,164 @@ def _counting_fixed_step_solver(
|
|||||||
|
|
||||||
|
|
||||||
class IntegrateOdeTests(unittest.TestCase):
|
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:
|
def test_generic_solver_keeps_canonical_default_tolerance(self) -> None:
|
||||||
self.assertEqual(SolveIVPConfig().atol, 1.0e-8)
|
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.components.experimental.flow.resistive_pipe import ResistivePipe
|
||||||
from app.simulation.registry import get_component_model_spec
|
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 (
|
from app.simulation.systems.generic import (
|
||||||
GenericFluidSystem,
|
GenericFluidSystem,
|
||||||
SimulationPreparationError,
|
SimulationPreparationError,
|
||||||
@@ -259,7 +262,9 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
|
|||||||
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
|
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
|
||||||
network = compile_reactflow_network(chain_project())
|
network = compile_reactflow_network(chain_project())
|
||||||
progress: list[tuple[float, str]] = []
|
progress: list[tuple[float, str]] = []
|
||||||
result = GenericFluidSystem(network).simulate(
|
activity_tracker = SolverActivityTracker()
|
||||||
|
system = GenericFluidSystem(network)
|
||||||
|
result = system.simulate(
|
||||||
SolveIVPConfig(
|
SolveIVPConfig(
|
||||||
t_start=0.0,
|
t_start=0.0,
|
||||||
t_stop=0.01,
|
t_stop=0.01,
|
||||||
@@ -268,6 +273,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
sample_step=0.005,
|
sample_step=0.005,
|
||||||
progress_callback=lambda value, phase: progress.append((value, phase)),
|
progress_callback=lambda value, phase: progress.append((value, phase)),
|
||||||
|
activity_tracker=activity_tracker,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(result.success)
|
self.assertTrue(result.success)
|
||||||
@@ -281,6 +287,23 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("integrating", {phase for _, phase in progress})
|
self.assertIn("integrating", {phase for _, phase in progress})
|
||||||
self.assertIn("postprocessing", {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.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
|
||||||
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
|
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
|
||||||
total_mass = [
|
total_mass = [
|
||||||
@@ -315,6 +338,33 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
|
|||||||
pressure_flow["optimizerEvaluationCount"],
|
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:
|
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
|
||||||
cancel_event = threading.Event()
|
cancel_event = threading.Event()
|
||||||
|
|
||||||
@@ -569,10 +619,24 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
|
|||||||
self.assertEqual(snapshot["result"]["status"], "stopped")
|
self.assertEqual(snapshot["result"]["status"], "stopped")
|
||||||
|
|
||||||
def test_streaming_endpoint_keeps_quiet_solver_connection_alive(self) -> None:
|
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", "正在进行时间积分与压力流量求解")
|
progress_callback(49, "integrating", "正在进行时间积分与压力流量求解")
|
||||||
time.sleep(0.03)
|
self.assertIsNotNone(activity_tracker)
|
||||||
return {"success": True}
|
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 (
|
with (
|
||||||
patch("app.main.SIMULATION_STREAM_HEARTBEAT_SECONDS", 0.005),
|
patch("app.main.SIMULATION_STREAM_HEARTBEAT_SECONDS", 0.005),
|
||||||
@@ -590,7 +654,21 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
|
|||||||
self.assertGreaterEqual(len(heartbeats), 1)
|
self.assertGreaterEqual(len(heartbeats), 1)
|
||||||
self.assertTrue(all(event["progress"] == 49 for event in heartbeats))
|
self.assertTrue(all(event["progress"] == 49 for event in heartbeats))
|
||||||
self.assertTrue(all(event["phase"] == "integrating" 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]["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:
|
def test_streaming_endpoint_returns_structured_validation_error(self) -> None:
|
||||||
events = [
|
events = [
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class MaxStepMatrixTests(unittest.TestCase):
|
|||||||
DEFAULT_MANIFEST_PATH,
|
DEFAULT_MANIFEST_PATH,
|
||||||
horizon_case_ids=("1s", 2.0),
|
horizon_case_ids=("1s", 2.0),
|
||||||
max_steps=(0.01, 0.05),
|
max_steps=(0.01, 0.05),
|
||||||
additional_checkpoint_times=(1.0885267285, 1.0997760276),
|
additional_checkpoint_times=(1.08, 1.10),
|
||||||
expected_projection_count=2,
|
expected_projection_count=2,
|
||||||
case_executor=execute,
|
case_executor=execute,
|
||||||
)
|
)
|
||||||
@@ -180,7 +180,7 @@ class MaxStepMatrixTests(unittest.TestCase):
|
|||||||
custom_requests = [request for request in requests if request.stop_time == 2.0]
|
custom_requests = [request for request in requests if request.stop_time == 2.0]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
custom_requests[0].checkpoint_times,
|
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(
|
self.assertEqual(
|
||||||
report["configuration"]["horizons"][1]["horizonCaseId"],
|
report["configuration"]["horizons"][1]["horizonCaseId"],
|
||||||
@@ -248,6 +248,50 @@ class MaxStepMatrixTests(unittest.TestCase):
|
|||||||
prefix_comparisons[0]["stateProjection"]["valueMismatchCount"], 0
|
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:
|
def test_horizons_must_increase_and_timeout_override_is_bounded(self) -> None:
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
RegressionManifestError, "strictly increasing"
|
RegressionManifestError, "strictly increasing"
|
||||||
@@ -259,6 +303,20 @@ class MaxStepMatrixTests(unittest.TestCase):
|
|||||||
expected_projection_count=2,
|
expected_projection_count=2,
|
||||||
case_executor=_fake_completed,
|
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"):
|
with self.assertRaisesRegex(RegressionManifestError, "must exceed"):
|
||||||
run_max_step_matrix(
|
run_max_step_matrix(
|
||||||
DEFAULT_MANIFEST_PATH,
|
DEFAULT_MANIFEST_PATH,
|
||||||
|
|||||||
@@ -5,24 +5,73 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from app.main import compile_reactflow_network
|
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 (
|
from app.simulation.solvers.algebraic import (
|
||||||
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
|
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
|
||||||
|
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE,
|
||||||
|
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE,
|
||||||
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
|
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
|
||||||
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
|
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
|
||||||
|
PressureFlowSolver,
|
||||||
)
|
)
|
||||||
from app.simulation.systems.generic import GenericFluidSystem
|
from app.simulation.systems.generic import GenericFluidSystem
|
||||||
from tests.test_amesim_mechanical_xml import zero_force_mass_project
|
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 (
|
from tests.test_amesim_pnvo001_signal_xml import (
|
||||||
high_pressure_helium_step_project,
|
high_pressure_helium_step_project,
|
||||||
)
|
)
|
||||||
from tests.test_generic_system_xml_simulation import chain_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(
|
def _system(
|
||||||
project,
|
project,
|
||||||
*,
|
*,
|
||||||
executor_v2: bool | None = False,
|
executor_v2: bool | None = False,
|
||||||
coordinate_kernel: bool | None = None,
|
coordinate_kernel: bool | None = None,
|
||||||
|
direct_sum_assignments: bool | None = None,
|
||||||
|
direct_equation_readers: bool | None = None,
|
||||||
) -> GenericFluidSystem:
|
) -> GenericFluidSystem:
|
||||||
environment = {}
|
environment = {}
|
||||||
if executor_v2 is not None:
|
if executor_v2 is not None:
|
||||||
@@ -33,6 +82,14 @@ def _system(
|
|||||||
environment[CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE] = (
|
environment[CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE] = (
|
||||||
"1" if coordinate_kernel else "0"
|
"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(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
environment,
|
environment,
|
||||||
@@ -44,10 +101,299 @@ def _system(
|
|||||||
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
|
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
|
||||||
None,
|
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))
|
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):
|
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:
|
def test_compiled_v2_is_enabled_by_default_and_can_be_disabled(self) -> None:
|
||||||
default = _system(zero_force_mass_project(), executor_v2=None)
|
default = _system(zero_force_mass_project(), executor_v2=None)
|
||||||
disabled = _system(zero_force_mass_project(), executor_v2=False)
|
disabled = _system(zero_force_mass_project(), executor_v2=False)
|
||||||
|
|||||||
@@ -110,6 +110,62 @@ class TestMql8StaticRegressionTests(unittest.TestCase):
|
|||||||
expected["hasMechanicalStateEvents"],
|
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:
|
def test_signal_event_schedule_is_fixed_for_every_horizon(self) -> None:
|
||||||
assert self.validation.document is not None
|
assert self.validation.document is not None
|
||||||
system = GenericFluidSystem(
|
system = GenericFluidSystem(
|
||||||
|
|||||||
Reference in new issue
Block a user