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

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

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

No files matched your search

@@ -257,7 +257,10 @@
"environment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
@@ -0,0 +1,86 @@
{
"schemaVersion": 1,
"generatedAt": "2026-08-18T19:06:44Z",
"purpose": "Compact evidence for the real streaming API path and activity-aware browser watchdog.",
"source": {
"xmlPath": "tests/data/test-mql-8.xml",
"xmlSha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b",
"jsonPath": "tests/data/test-mql-8.json",
"jsonSha256": "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18"
},
"request": {
"endpoint": "/api/system-xml/simulate-stream",
"simulationId": "diag-opt00-api-optimized-20260818",
"method": "BDF",
"tStart": 0.0,
"tStop": 0.2,
"sampleStep": 0.01,
"maxStep": 0.001
},
"response": {
"httpStatus": 200,
"contentType": "application/x-ndjson",
"phase": "completed",
"success": true,
"simulatedUntil": 0.2,
"sampleCount": 21,
"strictlyIncreasingTimes": true,
"allNumericSeriesFinite": true,
"progressEventCount": 255,
"resultEventCount": 1,
"integrationSeconds": 146.675315209,
"totalSeconds": 147.298753857,
"traceFirstProgressToResultSeconds": 147.481,
"integrationTotals": {
"nfev": 6318,
"njev": 331,
"nlu": 1060,
"acceptedStepCount": 2113,
"solverStartCount": 2,
"stateTransitionCount": 0,
"recoverableRetryCount": 0
}
},
"activityWindow": {
"maximumNonHeartbeatProgressGapSeconds": 57.185072,
"start": {
"wallTimeUtc": "18:46:24.877853",
"simulatedTime": 0.048633224495,
"activitySequence": 32081,
"acceptedStepSequence": 358,
"rhsCallCount": 7840
},
"end": {
"wallTimeUtc": "18:47:22.062925",
"simulatedTime": 0.049248430694,
"activitySequence": 68103,
"acceptedStepSequence": 1743,
"rhsCallCount": 16153
},
"interveningHeartbeatCount": 11,
"maximumInterEventGapSeconds": 5.074026,
"heartbeatActivityAdvanced": true,
"acceptedTimeAdvancedInsideThrottledProgressWindow": true,
"oldFrontendWouldUseAcceptedProgressOnly": true,
"activityAwareFrontendWouldCancel": false
},
"workerComparison": {
"reportPath": "tests/baselines/simulation/test_mql_8/runs/2026-08-18-production-slow-region-exact-v1-0.2.json",
"reportSha256": "34268e5864f33709018d7cbe631654d2a835b10dd252886509244f913ae18559",
"reportBytes": 437852,
"finalValuesEqual": true,
"integrationCountersEqual": true,
"numericalDiagnosticsEqual": true
},
"rawCapture": {
"retainedOutsideRepository": true,
"ndjsonSha256": "52bcca58147e1c78e5137d7134f79d21b314092156e0de0e9c3ebb58b40fd616",
"traceSha256": "8a26a349b40556811f9a0f151ebe801d6a126c782c030fe55cfb23c9630a8a90",
"headersSha256": "b5baf9b3d5ef1f2c18a9fb4894e78be6422b9e7c8f8821f2e586e92d21348a71"
},
"limitations": [
"The literal interactive browser click path was not captured in this artifact; the same watchdog state machine is covered by Playwright.",
"SciPy-internal finite-difference Jacobian builds are reflected by njev but are not classified by the realtime jacobianEvaluationCount field.",
"Cooperative cancellation cannot hard-kill a native or Python call that never returns."
]
}
@@ -0,0 +1,321 @@
{
"schemaVersion": 1,
"kind": "live-browser-stream-verification",
"generatedAt": "2026-08-19T15:12:44.536Z",
"startedAt": "2026-08-19T15:10:08.400Z",
"wallSeconds": 156.136,
"workflowCompleted": true,
"topology": {
"browserOrigin": "http://127.0.0.1:14173",
"backendOrigin": "http://127.0.0.1:18181",
"servingMode": "isolated-vite-dev-proxy"
},
"source": {
"path": "tests/data/test-mql-8.json",
"bytes": 272592,
"sha256": "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18"
},
"target": {
"tStart": 0,
"tStop": 0.2,
"sampleStep": 0.01,
"maxStep": 0.001,
"method": "BDF"
},
"runtimeEnvironment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
"request": {
"simulationId": "e694d041-129d-4b49-af9e-747d85f0056d",
"xmlBytes": 104110,
"simulationAttributes": {
"tStart": "0",
"tStop": "0.2",
"sampleStep": "0.01",
"maxStep": "0.001",
"method": "BDF"
}
},
"ndjson": {
"lineCount": 256,
"parseErrors": [],
"streamErrors": [],
"result": {
"success": true,
"status": "completed",
"partial": false,
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
"sampleCount": 21,
"timePointCount": 21,
"finalTime": 0.2
},
"activity": {
"progressEventCount": 255,
"heartbeatCount": 13,
"activityTelemetryHeartbeatCount": 13,
"firstActivitySequence": 25114,
"lastActivitySequence": 66670,
"activitySequenceAdvanced": true,
"acceptedTransitionCount": 249,
"maximumAcceptedPlateau": {
"durationMs": 5050.799999237061,
"fromAcceptedStepSequence": 1268,
"toAcceptedStepSequence": 1404,
"fromAcceptedTime": 0.04886080559693986,
"toAcceptedTime": 0.048885635713233705,
"heartbeatCount": 2,
"activitySequenceStart": 57190,
"activitySequenceEnd": 60152,
"activitySequenceAdvanced": true
},
"slowRegionEvents": [
{
"receivedAtMs": 43039.39999961853,
"phase": "integrating",
"heartbeat": false,
"progress": 24,
"simulatedTime": 0.04807049151065195,
"totalTime": 0.2,
"activitySequence": 32061,
"activityKind": "accepted_step",
"currentTrialTime": 0.04807049151065195,
"rhsCallCount": 7836,
"acceptedStepSequence": 356,
"acceptedTime": 0.04807049151065195
},
{
"receivedAtMs": 43131.29999923706,
"phase": "integrating",
"heartbeat": false,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 32081,
"activityKind": "accepted_step",
"currentTrialTime": 0.0486332244950324,
"rhsCallCount": 7840,
"acceptedStepSequence": 358,
"acceptedTime": 0.0486332244950324
},
{
"receivedAtMs": 48131,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 35600,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.0486991505296288,
"rhsCallCount": 8689,
"acceptedStepSequence": 420,
"acceptedTime": 0.048699149085530725
},
{
"receivedAtMs": 53157.89999961853,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 38734,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04870448422554062,
"rhsCallCount": 9413,
"acceptedStepSequence": 539,
"acceptedTime": 0.04870448402151976
},
{
"receivedAtMs": 58201.89999961853,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 41712,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04872888403105117,
"rhsCallCount": 10076,
"acceptedStepSequence": 702,
"acceptedTime": 0.048728801929735416
},
{
"receivedAtMs": 63233.59999847412,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 44741,
"activityKind": "state_derivatives",
"currentTrialTime": 0.04872901764870005,
"rhsCallCount": 10765,
"acceptedStepSequence": 838,
"acceptedTime": 0.04872901502576142
},
{
"receivedAtMs": 68278.29999923706,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 47988,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04880991845348599,
"rhsCallCount": 11549,
"acceptedStepSequence": 894,
"acceptedTime": 0.04880991659730141
},
{
"receivedAtMs": 73322.5,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 50944,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04885091695689501,
"rhsCallCount": 12205,
"acceptedStepSequence": 1060,
"acceptedTime": 0.048849397131310805
},
{
"receivedAtMs": 78344,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 53758,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04885415602948481,
"rhsCallCount": 12830,
"acceptedStepSequence": 1217,
"acceptedTime": 0.048852938260664384
},
{
"receivedAtMs": 83372,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 57190,
"activityKind": "solver_step",
"currentTrialTime": 0.048860806745699516,
"rhsCallCount": 13662,
"acceptedStepSequence": 1268,
"acceptedTime": 0.04886080559693986
},
{
"receivedAtMs": 88422.79999923706,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 60152,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04888569214870155,
"rhsCallCount": 14335,
"acceptedStepSequence": 1404,
"acceptedTime": 0.048885635713233705
},
{
"receivedAtMs": 93441.29999923706,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 63490,
"activityKind": "thermofluid_closure",
"currentTrialTime": 0.04888577358403684,
"rhsCallCount": 15121,
"acceptedStepSequence": 1501,
"acceptedTime": 0.048885772226330206
},
{
"receivedAtMs": 98461.89999961853,
"phase": "integrating",
"heartbeat": true,
"progress": 24,
"simulatedTime": 0.0486332244950324,
"totalTime": 0.2,
"activitySequence": 66670,
"activityKind": "solver_step",
"currentTrialTime": 0.04888814764518708,
"rhsCallCount": 15855,
"acceptedStepSequence": 1622,
"acceptedTime": 0.048888146224637836
},
{
"receivedAtMs": 101649.59999847412,
"phase": "integrating",
"heartbeat": false,
"progress": 25,
"simulatedTime": 0.049248430694378086,
"totalTime": 0.2,
"activitySequence": 68103,
"activityKind": "accepted_step",
"currentTrialTime": 0.049248430694378086,
"rhsCallCount": 16153,
"acceptedStepSequence": 1743,
"acceptedTime": 0.049248430694378086
},
{
"receivedAtMs": 101755.69999885559,
"phase": "integrating",
"heartbeat": false,
"progress": 25,
"simulatedTime": 0.049780741149075,
"totalTime": 0.2,
"activitySequence": 68133,
"activityKind": "accepted_step",
"currentTrialTime": 0.049780741149075,
"rhsCallCount": 16159,
"acceptedStepSequence": 1746,
"acceptedTime": 0.049780741149075
},
{
"receivedAtMs": 102123.79999923706,
"phase": "integrating",
"heartbeat": false,
"progress": 25,
"simulatedTime": 0.05031324569277993,
"totalTime": 0.2,
"activitySequence": 68377,
"activityKind": "accepted_step",
"currentTrialTime": 0.05031324569277993,
"rhsCallCount": 16219,
"acceptedStepSequence": 1748,
"acceptedTime": 0.05031324569277993
}
]
}
},
"browser": {
"cancellationRequests": [],
"pageErrors": [],
"consoleErrors": [
"Failed to load resource: the server responded with a status of 404 (Not Found)"
]
},
"acceptance": {
"completedWith21Samples": true,
"noCancellationRequest": true,
"noStreamError": true,
"noPageError": true,
"activityTelemetryAdvanced": true
}
}
@@ -129,7 +129,10 @@
"environment": {
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on"
},
+135
View File
@@ -10,6 +10,7 @@ from app.main import (
compile_system_xml_network,
run_system_xml_simulation,
)
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port
@@ -302,6 +303,140 @@ class AmesimMechanicalXmlTests(unittest.TestCase):
self.assertEqual(tolerances, [1.0e-12, 1.0e-12])
self.assertEqual(len(tolerances), len(system.initial_state_vector()))
def test_self_loop_contact_does_not_relax_mass_velocity(self) -> None:
contact_parameters = {**LSTP00A_DEFAULTS, "rcont": 1.0e11}
project = ReactFlowProjectPayload(
projectSchemaVersion=1,
name="amesim-mechanical-self-contact",
nodes=[
component_node(
"mass_1",
"amesim_mecmas21",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
MECMAS21_DEFAULTS,
),
component_node(
"contact_1",
"amesim_lstp00a",
[
mechanical_port("port_1", "left"),
mechanical_port("port_2", "right"),
],
contact_parameters,
),
],
edges=[
physical_edge(
"edge-1",
"mass_1",
"port_1",
"contact_1",
"port_1",
),
physical_edge(
"edge-2",
"mass_1",
"port_2",
"contact_1",
"port_2",
),
],
simulation={
"t_start": 0.0,
"t_stop": 0.02,
"step": 0.01,
"max_step": 0.005,
"method": "BDF",
},
)
system = GenericFluidSystem(compile_reactflow_network(project))
plan = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
self.assertEqual(plan.values, (1.0e-12, 1.0e-12))
self.assertEqual(plan.groups[0].contacts, ())
self.assertFalse(plan.groups[0].eligible)
self.assertEqual(plan.groups[0].reason, "noFlexibleContact")
def test_weak_contact_damping_does_not_relax_velocity(self) -> None:
project = elastic_contact_project()
project.nodes[2].data.parameters["rcont"] = 1.0e-300
system = GenericFluidSystem(compile_reactflow_network(project))
plan = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
self.assertTrue(plan.groups)
self.assertTrue(all(not group.eligible for group in plan.groups))
self.assertTrue(
all(group.reason == "weakContactDamping" for group in plan.groups)
)
self.assertTrue(
all(
group.velocity_atol == 1.0e-12
and group.position_atol == 1.0e-12
for group in plan.groups
)
)
def test_legacy_tolerance_ignores_extreme_contact_scales(self) -> None:
project = elastic_contact_project()
project.nodes[2].data.parameters.update(
{"kcont": 5.0e-324, "Pdis": 5.0e-324, "rcont": 1.0}
)
system = GenericFluidSystem(compile_reactflow_network(project))
legacy = system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="legacy",
)
contact_aware = (
system.mechanical_state_reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
)
self.assertTrue(all(value == 1.0e-12 for value in legacy.values))
self.assertTrue(
all(group.reason == "legacyMode" for group in legacy.groups)
)
self.assertTrue(
all(
group.reason == "invalidContactScale"
for group in contact_aware.groups
)
)
def test_contact_aware_tolerance_is_limited_to_bdf(self) -> None:
system = GenericFluidSystem(
compile_reactflow_network(zero_force_mass_project())
)
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.001,
method="RK45",
max_step=0.001,
),
sample_step=0.001,
)
self.assertTrue(result.success)
tolerance = result.diagnostics["integration"][
"mechanicalAbsoluteTolerance"
]
self.assertEqual(tolerance["mode"], "legacy")
def test_sparse_reactflow_mecmas_parameters_use_registered_defaults(self) -> None:
project = mecmas_project(1.0, 1.0)
+201 -2
View File
@@ -1,13 +1,42 @@
from __future__ import annotations
import unittest
from math import sqrt
from math import log10, sqrt
from unittest.mock import patch
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl0001,
AmesimPnl0002,
AmesimPnl00r,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.registry import COMPONENT_MODEL_REGISTRY
class _ScaledReynoldsPnl0001(AmesimPnl0001):
def __init__(self, *args, reynolds_scale: float, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.reynolds_scale = float(reynolds_scale)
self.reynolds_calls: list[tuple[float, float]] = []
def reynolds_number(self, mass_flow: float, temperature: float) -> float:
self.reynolds_calls.append((mass_flow, temperature))
return self.reynolds_scale * super().reynolds_number(
mass_flow,
temperature,
)
class _DelegatingFrictionPnl0001(AmesimPnl0001):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.friction_reynolds: list[float] = []
def friction_factor(self, reynolds_number: float) -> float:
self.friction_reynolds.append(reynolds_number)
return super().friction_factor(reynolds_number)
class AmesimPnl0001ComponentTests(unittest.TestCase):
def setUp(self) -> None:
self.medium = IdealGasMedium()
@@ -103,6 +132,176 @@ class AmesimPnl0001ComponentTests(unittest.TestCase):
self.assertEqual(pipe.mass_flow(15.3e6, 15.3e6 + 2.0e-9, 293.15), 0.0)
def test_overridden_reynolds_number_is_called_on_every_iteration(self) -> None:
slow_reynolds = _ScaledReynoldsPnl0001(
"slow_reynolds",
self.medium,
reynolds_scale=0.25,
)
fast_reynolds = _ScaledReynoldsPnl0001(
"fast_reynolds",
self.medium,
reynolds_scale=4.0,
)
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": 1.0e6,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
slow_flow = slow_reynolds._one_way_pn2pipefr_mass_flow(**options)
fast_flow = fast_reynolds._one_way_pn2pipefr_mass_flow(**options)
self.assertEqual(len(slow_reynolds.reynolds_calls), 4)
self.assertEqual(len(fast_reynolds.reynolds_calls), 4)
self.assertTrue(
all(
temperature == 300.0
for _mass_flow, temperature in slow_reynolds.reynolds_calls
)
)
self.assertLess(slow_flow, fast_flow)
def test_zero_iterations_does_not_evaluate_dynamic_viscosity(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
original = IdealGasMedium.dynamic_viscosity
with (
patch.object(
IdealGasMedium,
"dynamic_viscosity",
autospec=True,
side_effect=original,
) as dynamic_viscosity,
patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm,
):
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=0,
)
self.assertGreater(flow, 0.0)
dynamic_viscosity.assert_not_called()
logarithm.assert_not_called()
def test_builtin_iterations_evaluate_dynamic_viscosity_once(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
original = IdealGasMedium.dynamic_viscosity
with patch.object(
IdealGasMedium,
"dynamic_viscosity",
autospec=True,
side_effect=original,
) as dynamic_viscosity:
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=4,
)
self.assertGreater(flow, 0.0)
dynamic_viscosity.assert_called_once_with(self.medium, 300.0)
def test_builtin_fixed_point_evaluates_fully_rough_term_once(self) -> None:
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": 1.0e6,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
pipes = (
AmesimPnl00r("pnl_00r", self.medium, rr=1.0e-5),
AmesimPnl0001("pnl_0001", self.medium, rr=1.0e-5),
AmesimPnl0002("pnl_0002", self.medium, rr=1.0e-5),
)
for pipe in pipes:
with self.subTest(model=type(pipe).__name__):
with patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm:
flow = AmesimPnl0001._one_way_pn2pipefr_mass_flow(
pipe,
**options,
)
self.assertGreater(flow, 0.0)
fully_rough_argument = pipe.rr / 3.7
self.assertEqual(
sum(
call.args == (fully_rough_argument,)
for call in logarithm.call_args_list
),
1,
)
self.assertEqual(logarithm.call_count, 5)
def test_overridden_friction_factor_keeps_virtual_iteration_path(self) -> None:
pipe = _DelegatingFrictionPnl0001(
"custom_friction",
self.medium,
rr=1.0e-5,
)
with patch(
"app.simulation.components.amesim.flow.pipes.log10",
side_effect=log10,
) as logarithm:
flow = pipe._one_way_pn2pipefr_mass_flow(
upstream_pressure=2.0e6,
downstream_pressure=1.0e6,
upstream_temperature=300.0,
resistance_length=1.0,
max_iterations=4,
)
self.assertGreater(flow, 0.0)
self.assertEqual(len(pipe.friction_reynolds), 4)
self.assertEqual(logarithm.call_count, 8)
def test_precomputed_fully_rough_path_is_bitwise_identical_by_regime(
self,
) -> None:
for index, downstream_pressure in enumerate(
(1_999_999.999, 1_999_990.0, 1_999_900.0, 1.0e6)
):
with self.subTest(downstream_pressure=downstream_pressure):
built_in = AmesimPnl0001(
f"built_in_{index}",
self.medium,
rr=1.0e-5,
)
virtual = _DelegatingFrictionPnl0001(
f"virtual_{index}",
self.medium,
rr=1.0e-5,
)
options = {
"upstream_pressure": 2.0e6,
"downstream_pressure": downstream_pressure,
"upstream_temperature": 300.0,
"resistance_length": 1.0,
"max_iterations": 4,
}
actual = built_in._one_way_pn2pipefr_mass_flow(**options)
expected = virtual._one_way_pn2pipefr_mass_flow(**options)
self.assertEqual(actual.hex(), expected.hex())
def test_pressure_flow_residuals_do_not_force_storage_mass_balance(self) -> None:
pipe = AmesimPnl0001("pnl_1", self.medium)
pipe.port_1.p = 101000.0
+3
View File
@@ -672,7 +672,10 @@ class ProgressiveSuiteTests(unittest.TestCase):
[{
"SIMULATION_CAUSAL_EXECUTOR_V2": "1",
"SIMULATION_CAUSAL_COORDINATE_KERNEL": "1",
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": "1",
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": "1",
"SIMULATION_CAUSAL_FAST_PATH": "1",
"SIMULATION_MECHANICAL_ATOL_MODE": "legacy",
"SIMULATION_ODE_JACOBIAN_MODE": "scipy",
"SIMULATIONAPP_PROPERTY_CACHE": "on",
}],
+133 -3
View File
@@ -12,6 +12,7 @@ from app.simulation.solvers.causal_ir import (
from app.simulation.systems.generic import GenericFluidSystem
from app.system_xml import validate_system_xml_document
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnl0001_xml import amesim_pnl0001_project
from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project
from tests.test_generic_system_xml_simulation import chain_project
@@ -19,11 +20,102 @@ from tests.test_generic_system_xml_simulation import chain_project
TARGET_XML = Path("tests/data/test-mql-8.xml")
class _SyntheticDirectReaderError(Exception):
pass
def _system(project) -> GenericFluidSystem:
return GenericFluidSystem(compile_reactflow_network(project))
class CausalNumericIRTests(unittest.TestCase):
def _assert_direct_reader_exception_is_controlled(
self,
exception: Exception,
) -> None:
system = _system(amesim_pnl0001_project())
solver = system.pressure_flow_solver
compilation = compile_causal_numeric_ir(solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
ir = compilation.ir
direct_evaluation = next(
evaluation
for stage in ir.program.effort_stages
for evaluation in stage.evaluations
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
)
evaluators = list(ir.bindings.evaluators)
victim = solver.unknowns[-1]
before = tuple(unknown.read() for unknown in solver.unknowns)
def failing_reader() -> float:
victim.write(victim.read() + 123.0)
raise exception
evaluators[direct_evaluation.evaluator_slot] = failing_reader
failing_ir = replace(
ir,
bindings=replace(ir.bindings, evaluators=tuple(evaluators)),
)
result = failing_ir.execute(
failing_ir.create_workspace(),
transactional=True,
)
self.assertFalse(result.success)
self.assertEqual(
result.fallback_reason,
f"effortEvaluationFailed:{type(exception).__name__}",
)
self.assertTrue(result.rolled_back)
self.assertEqual(
tuple(unknown.read() for unknown in solver.unknowns),
before,
)
def test_direct_reader_key_error_returns_controlled_failure(self) -> None:
self._assert_direct_reader_exception_is_controlled(
KeyError("synthetic direct-reader key failure")
)
def test_direct_reader_custom_exception_returns_controlled_failure(
self,
) -> None:
self._assert_direct_reader_exception_is_controlled(
_SyntheticDirectReaderError("synthetic direct-reader failure")
)
def test_direct_reader_memory_error_remains_fatal(self) -> None:
system = _system(amesim_pnl0001_project())
compilation = compile_causal_numeric_ir(system.pressure_flow_solver)
self.assertTrue(compilation.supported, compilation.fallback_reason)
assert compilation.ir is not None
ir = compilation.ir
direct_evaluation = next(
evaluation
for stage in ir.program.effort_stages
for evaluation in stage.evaluations
if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
)
evaluators = list(ir.bindings.evaluators)
def failing_reader() -> float:
raise MemoryError("synthetic direct-reader allocation failure")
evaluators[direct_evaluation.evaluator_slot] = failing_reader
failing_ir = replace(
ir,
bindings=replace(ir.bindings, evaluators=tuple(evaluators)),
)
with self.assertRaisesRegex(MemoryError, "allocation failure"):
failing_ir.execute(
failing_ir.create_workspace(),
transactional=True,
)
def test_structure_signature_is_stable_and_excludes_bindings(self) -> None:
first = compile_causal_numeric_ir(
_system(zero_force_mass_project()).pressure_flow_solver
@@ -242,18 +334,56 @@ class TargetCausalNumericIRStructureTests(unittest.TestCase):
self.assertEqual(len(pressure_stage.operations), 76)
self.assertEqual(len(pressure_stage.evaluations), 40)
self.assertTrue(
all(
self.assertEqual(
sum(
evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL
for evaluation in pressure_stage.evaluations
),
20,
)
self.assertEqual(
sum(
evaluation.opcode
is CausalIROpcode.EFFORT_COMPONENT_RESIDUAL
for evaluation in pressure_stage.evaluations
)
),
20,
)
self.assertEqual(
self.system.pressure_flow_solver.causal_execution_diagnostics()[
"directEffortAnchorCount"
],
20,
)
self.assertEqual(
[len(stage.target_slots) for stage in program.flow_stages],
[114, 134, 49, 33, 5, 1],
)
def test_target_preserves_exact_direct_sum_assignment_counts(self) -> None:
solver = self.system.pressure_flow_solver
self.assertEqual(
solver.causal_execution_diagnostics()[
"directSumFlowAssignmentCount"
],
38,
)
component_equation_ids = frozenset(
equation.id
for evaluation in solver._component_equation_plan
for equation in evaluation.templates
)
secondary = (
self.system._thermofluid_closure_plan.secondary_block_solvers[0]
)
secondary_direct_count = sum(
assignment.evaluate is not None
and assignment.equation_id in component_equation_ids
for stage in secondary._selected_explicit_flow_plan
for assignment in stage.assignments
)
self.assertEqual(secondary_direct_count, 36)
def test_target_reference_execution_matches_all_compatibility_slots(self) -> None:
assert self.compilation.ir is not None
solver = self.system.pressure_flow_solver
+160
View File
@@ -1,5 +1,6 @@
import math
import sys
import threading
import types
import unittest
from unittest.mock import patch
@@ -8,6 +9,7 @@ from app.simulation.core.errors import RecoverableTrialStateError
from app.simulation.solvers.solver import (
IntegrationCancelled,
SolveIVPConfig,
SolverActivityTracker,
StateTransition,
integrate_ode,
)
@@ -44,6 +46,164 @@ def _counting_fixed_step_solver(
class IntegrateOdeTests(unittest.TestCase):
def test_activity_tracking_preserves_the_exact_trajectory(self) -> None:
options = {
"rhs": lambda time, state: [
-0.5 * float(state[0]) + 0.1 * float(time)
],
"initial_state": [1.0],
"config": SolveIVPConfig(
t_start=0.0,
t_stop=0.1,
method="BDF",
max_step=0.01,
),
"t_eval": [0.0, 0.025, 0.05, 0.075, 0.1],
"cancel_check": lambda: False,
}
baseline = integrate_ode(**options)
tracked = integrate_ode(
**options,
activity_tracker=SolverActivityTracker(),
)
self.assertTrue(baseline.success, baseline.message)
self.assertTrue(tracked.success, tracked.message)
self.assertEqual(tracked.t, baseline.t)
self.assertEqual(tracked.y, baseline.y)
self.assertEqual(tracked.solver_segments, baseline.solver_segments)
def test_activity_advances_while_accepted_time_is_unchanged(self) -> None:
import numpy as np
rhs_started = threading.Event()
release_rhs = threading.Event()
tracker = SolverActivityTracker()
results = []
errors: list[BaseException] = []
class BlockingSolver:
def __init__(self, fun, t0, y0, t_bound, **_kwargs):
self.fun = fun
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.status = "running"
self.nfev = 0
self.njev = 0
self.nlu = 0
def step(self):
self.fun(0.25, self.y)
self.nfev += 1
self.t = self.t_bound
self.y = np.asarray([self.t], dtype=float)
self.status = "finished"
return None
def rhs(_time, _state):
rhs_started.set()
if not release_rhs.wait(timeout=2.0):
raise TimeoutError("test did not release the blocking RHS")
return [1.0]
def run_integration() -> None:
try:
results.append(
integrate_ode(
rhs=rhs,
initial_state=[0.0],
config=SolveIVPConfig(t_stop=1.0, method="BDF"),
cancel_check=lambda: False,
activity_tracker=tracker,
)
)
except BaseException as exc: # pragma: no cover - test handoff
errors.append(exc)
with patch("scipy.integrate.BDF", BlockingSolver):
worker = threading.Thread(target=run_integration)
worker.start()
self.assertTrue(rhs_started.wait(timeout=2.0))
during_rhs = tracker.snapshot()
self.assertEqual(during_rhs.activity_kind, "rhs")
self.assertGreater(during_rhs.activity_sequence, 0)
self.assertEqual(during_rhs.current_trial_time, 0.25)
self.assertEqual(during_rhs.rhs_call_count, 1)
self.assertEqual(during_rhs.accepted_step_sequence, 0)
self.assertEqual(during_rhs.accepted_time, 0.0)
release_rhs.set()
worker.join(timeout=2.0)
self.assertFalse(worker.is_alive())
self.assertEqual(errors, [])
self.assertTrue(results[0].success, results[0].message)
completed = tracker.snapshot()
self.assertEqual(completed.accepted_step_sequence, 1)
self.assertEqual(completed.accepted_time, 1.0)
self.assertEqual(completed.solver_step_sequence, 1)
def test_activity_distinguishes_blocking_jacobian_evaluation(self) -> None:
import numpy as np
jacobian_started = threading.Event()
release_jacobian = threading.Event()
tracker = SolverActivityTracker()
results = []
class BlockingSolver:
def __init__(self, _fun, t0, y0, t_bound, **options):
self.jac = options["jac"]
self.t = float(t0)
self.y = np.asarray(y0, dtype=float)
self.t_bound = float(t_bound)
self.status = "running"
self.nfev = 0
self.njev = 0
self.nlu = 0
def step(self):
self.jac(0.5, self.y)
self.njev += 1
self.t = self.t_bound
self.status = "finished"
return None
class BlockingJacobian:
def __call__(self, _time, _state):
jacobian_started.set()
if not release_jacobian.wait(timeout=2.0):
raise TimeoutError("test did not release the Jacobian")
return np.asarray([[0.0]])
def run_integration() -> None:
results.append(
integrate_ode(
rhs=lambda _time, _state: [0.0],
initial_state=[0.0],
config=SolveIVPConfig(t_stop=1.0, method="BDF"),
cancel_check=lambda: False,
jac=BlockingJacobian(),
activity_tracker=tracker,
)
)
with patch("scipy.integrate.BDF", BlockingSolver):
worker = threading.Thread(target=run_integration)
worker.start()
self.assertTrue(jacobian_started.wait(timeout=2.0))
during_jacobian = tracker.snapshot()
self.assertEqual(during_jacobian.activity_kind, "jacobian")
self.assertEqual(during_jacobian.jacobian_evaluation_count, 1)
self.assertEqual(during_jacobian.rhs_call_count, 0)
self.assertEqual(during_jacobian.accepted_step_sequence, 0)
release_jacobian.set()
worker.join(timeout=2.0)
self.assertFalse(worker.is_alive())
self.assertTrue(results[0].success, results[0].message)
def test_generic_solver_keeps_canonical_default_tolerance(self) -> None:
self.assertEqual(SolveIVPConfig().atol, 1.0e-8)
+83 -5
View File
@@ -26,7 +26,10 @@ from app.main import (
)
from app.simulation.components.experimental.flow.resistive_pipe import ResistivePipe
from app.simulation.registry import get_component_model_spec
from app.simulation.solvers.solver import SolveIVPConfig
from app.simulation.solvers.solver import (
SolveIVPConfig,
SolverActivityTracker,
)
from app.simulation.systems.generic import (
GenericFluidSystem,
SimulationPreparationError,
@@ -259,7 +262,9 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None:
network = compile_reactflow_network(chain_project())
progress: list[tuple[float, str]] = []
result = GenericFluidSystem(network).simulate(
activity_tracker = SolverActivityTracker()
system = GenericFluidSystem(network)
result = system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
@@ -268,6 +273,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
),
sample_step=0.005,
progress_callback=lambda value, phase: progress.append((value, phase)),
activity_tracker=activity_tracker,
)
self.assertTrue(result.success)
@@ -281,6 +287,23 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
)
self.assertIn("integrating", {phase for _, phase in progress})
self.assertIn("postprocessing", {phase for _, phase in progress})
activity = activity_tracker.snapshot()
self.assertEqual(activity.activity_kind, "complete")
self.assertGreater(activity.rhs_call_count, 0)
self.assertGreater(activity.accepted_step_sequence, 0)
self.assertEqual(activity.accepted_time, 0.01)
self.assertGreater(activity.thermofluid_closure_count, 0)
self.assertEqual(
result.diagnostics["activity"],
activity.as_dict(),
)
self.assertIsNone(system._activity_tracker)
self.assertEqual(
result.diagnostics["integration"][
"mechanicalAbsoluteTolerance"
]["mode"],
"legacy",
)
self.assertLess(result.series["cylinder_1.p"][-1], 500000.0)
self.assertGreater(result.series["tank_1.p"][-1], 100000.0)
total_mass = [
@@ -315,6 +338,33 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
pressure_flow["optimizerEvaluationCount"],
)
def test_activity_tracker_scope_is_restored_after_simulation_error(self) -> None:
system = GenericFluidSystem(compile_reactflow_network(chain_project()))
previous_tracker = SolverActivityTracker()
active_tracker = SolverActivityTracker()
system._activity_tracker = previous_tracker
with (
patch.object(
system,
"_simulate",
side_effect=RuntimeError("synthetic simulation failure"),
),
self.assertRaisesRegex(RuntimeError, "synthetic simulation failure"),
):
system.simulate(
SolveIVPConfig(
t_start=0.0,
t_stop=0.01,
method="BDF",
max_step=0.001,
),
sample_step=0.005,
activity_tracker=active_tracker,
)
self.assertIs(system._activity_tracker, previous_tracker)
def test_cancelled_simulation_returns_accepted_partial_samples(self) -> None:
cancel_event = threading.Event()
@@ -569,10 +619,24 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertEqual(snapshot["result"]["status"], "stopped")
def test_streaming_endpoint_keeps_quiet_solver_connection_alive(self) -> None:
def delayed_simulation(_xml_bytes, progress_callback, _cancel_check=None):
def delayed_simulation(
_xml_bytes,
progress_callback,
_cancel_check=None,
activity_tracker=None,
):
progress_callback(49, "integrating", "正在进行时间积分与压力流量求解")
time.sleep(0.03)
return {"success": True}
self.assertIsNotNone(activity_tracker)
activity_tracker.start_integration(0.0)
for trial_time in (0.0487, 0.0488, 0.0489, 0.0490):
activity_tracker.record_rhs(trial_time)
time.sleep(0.008)
return {
"success": True,
"status": "completed",
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
}
with (
patch("app.main.SIMULATION_STREAM_HEARTBEAT_SECONDS", 0.005),
@@ -590,7 +654,21 @@ class GenericSystemXmlSimulationTests(unittest.TestCase):
self.assertGreaterEqual(len(heartbeats), 1)
self.assertTrue(all(event["progress"] == 49 for event in heartbeats))
self.assertTrue(all(event["phase"] == "integrating" for event in heartbeats))
self.assertTrue(
all(event["acceptedStepSequence"] == 0 for event in heartbeats)
)
self.assertTrue(all(event["acceptedTime"] == 0.0 for event in heartbeats))
self.assertGreater(
len({event["activitySequence"] for event in heartbeats}),
1,
)
self.assertTrue(
all(event["activityKind"] == "rhs" for event in heartbeats)
)
self.assertEqual(events[-1]["event"], "result")
self.assertEqual(events[-1]["activityKind"], "complete")
self.assertEqual(events[-1]["acceptedStepSequence"], 0)
self.assertEqual(events[-1]["rhsCallCount"], 4)
def test_streaming_endpoint_returns_structured_validation_error(self) -> None:
events = [
+60 -2
View File
@@ -166,7 +166,7 @@ class MaxStepMatrixTests(unittest.TestCase):
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01, 0.05),
additional_checkpoint_times=(1.0885267285, 1.0997760276),
additional_checkpoint_times=(1.08, 1.10),
expected_projection_count=2,
case_executor=execute,
)
@@ -180,7 +180,7 @@ class MaxStepMatrixTests(unittest.TestCase):
custom_requests = [request for request in requests if request.stop_time == 2.0]
self.assertEqual(
custom_requests[0].checkpoint_times,
(0.0, 0.04, 0.8, 1.0, 1.0885267285, 1.0997760276, 2.0),
(0.0, 0.04, 0.8, 1.0, 1.08, 1.1, 2.0),
)
self.assertEqual(
report["configuration"]["horizons"][1]["horizonCaseId"],
@@ -248,6 +248,50 @@ class MaxStepMatrixTests(unittest.TestCase):
prefix_comparisons[0]["stateProjection"]["valueMismatchCount"], 0
)
def test_cross_horizon_comparison_excludes_short_terminal(self) -> None:
def execute(request: RegressionCaseRequest) -> dict[str, object]:
result = _fake_completed(request)
if request.stop_time <= 1.0:
checkpoints = result["worker"]["summary"]["physicalContract"][
"checkpoints"
]
for checkpoint in checkpoints:
if checkpoint["requestedTime"] == request.stop_time:
checkpoint["stateValues"]["state.a"] += 1.0
return result
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s", 2.0),
max_steps=(0.01,),
expected_projection_count=2,
case_executor=execute,
)
comparison = report["comparisons"]["sameMaxStepAcrossHorizons"][0]
self.assertTrue(comparison["strictPrefix"])
self.assertTrue(comparison["passed"])
self.assertNotIn(
1.0,
comparison["stateProjection"]["commonCheckpointTimes"],
)
def test_same_horizon_comparison_keeps_terminal_checkpoint(self) -> None:
report = run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01, 0.05),
expected_projection_count=2,
case_executor=_fake_completed,
)
comparison = report["comparisons"]["sameHorizonAcrossMaxSteps"][0]
self.assertFalse(comparison["strictPrefix"])
self.assertIn(
1.0,
comparison["stateProjection"]["commonCheckpointTimes"],
)
def test_horizons_must_increase_and_timeout_override_is_bounded(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError, "strictly increasing"
@@ -259,6 +303,20 @@ class MaxStepMatrixTests(unittest.TestCase):
expected_projection_count=2,
case_executor=_fake_completed,
)
def test_off_grid_checkpoint_is_rejected_before_execution(self) -> None:
with self.assertRaisesRegex(
RegressionManifestError,
"not represented by the .* output grid",
):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
horizon_case_ids=("1s",),
max_steps=(0.01,),
additional_checkpoint_times=(0.0489,),
expected_projection_count=2,
case_executor=_fake_completed,
)
with self.assertRaisesRegex(RegressionManifestError, "must exceed"):
run_max_step_matrix(
DEFAULT_MANIFEST_PATH,
@@ -5,24 +5,73 @@ import unittest
from unittest.mock import patch
from app.main import compile_reactflow_network
from app.simulation.components.amesim.flow.orifices import (
AmesimPnvo001SignalOpening,
)
from app.simulation.components.amesim.flow.pipes import AmesimPnl0001
from app.simulation.registry import ComponentModelSpec, get_component_model_spec
from app.simulation.solvers.algebraic import (
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE,
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE,
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE,
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE,
PressureFlowSolver,
)
from app.simulation.systems.generic import GenericFluidSystem
from tests.test_amesim_mechanical_xml import zero_force_mass_project
from tests.test_amesim_pnl0001_xml import amesim_pnl0001_project
from tests.test_amesim_pnvo001_signal_xml import (
high_pressure_helium_step_project,
)
from tests.test_generic_system_xml_simulation import chain_project
class _UntrustedPnvo001(AmesimPnvo001SignalOpening):
# A module-name check alone would incorrectly trust this changed subclass.
__module__ = AmesimPnvo001SignalOpening.__module__
pressure_flow_callback_count = 0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
self.pressure_flow_callback_count += 1
return super().pressure_flow_equation_values()
class _InheritedDirectReaderPnl0001(AmesimPnl0001):
"""A changed concrete class that does not repeat the scalar promise."""
class _NonfiniteDirectReaderPnl0001(AmesimPnl0001):
__module__ = AmesimPnl0001.__module__
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.full_equation_evaluation_count = 0
self.direct_equation_evaluation_count = 0
def pressure_flow_equation_values(self) -> tuple[float, ...]:
self.full_equation_evaluation_count += 1
return super().pressure_flow_equation_values()
def pressure_flow_equation_value_readers(self):
def nonfinite_pressure_state_residual() -> float:
self.direct_equation_evaluation_count += 1
return float("nan")
return {
f"{self.name}:port_2_pressure_state": (
nonfinite_pressure_state_residual
),
}
def _system(
project,
*,
executor_v2: bool | None = False,
coordinate_kernel: bool | None = None,
direct_sum_assignments: bool | None = None,
direct_equation_readers: bool | None = None,
) -> GenericFluidSystem:
environment = {}
if executor_v2 is not None:
@@ -33,6 +82,14 @@ def _system(
environment[CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE] = (
"1" if coordinate_kernel else "0"
)
if direct_sum_assignments is not None:
environment[CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE] = (
"1" if direct_sum_assignments else "0"
)
if direct_equation_readers is not None:
environment[CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE] = (
"1" if direct_equation_readers else "0"
)
with patch.dict(
os.environ,
environment,
@@ -44,10 +101,299 @@ def _system(
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE,
None,
)
if direct_sum_assignments is None:
os.environ.pop(
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE,
None,
)
if direct_equation_readers is None:
os.environ.pop(
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE,
None,
)
return GenericFluidSystem(compile_reactflow_network(project))
def _system_with_pnl0001_class(component_class, **options) -> GenericFluidSystem:
original_spec = get_component_model_spec("amesim_pnl0001")
custom_spec = ComponentModelSpec(
component_class=component_class,
library=original_spec.library,
)
def custom_spec_lookup(model_type: str):
if model_type == "amesim_pnl0001":
return custom_spec
return get_component_model_spec(model_type)
with patch(
"app.simulation.registry.get_component_model_spec",
side_effect=custom_spec_lookup,
):
return _system(amesim_pnl0001_project(), **options)
class PressureFlowCausalExecutionTests(unittest.TestCase):
def test_direct_equation_reader_is_default_on_and_bitwise_optional(
self,
) -> None:
direct = _system(
amesim_pnl0001_project(),
executor_v2=True,
direct_equation_readers=True,
)
callback = _system(
amesim_pnl0001_project(),
executor_v2=True,
direct_equation_readers=False,
)
direct_state = direct.initial_state_vector()
callback_state = callback.initial_state_vector()
for time in (0.0, 0.001):
self.assertEqual(
direct.rhs(time, direct_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in direct.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
direct_diagnostics = (
direct.pressure_flow_solver.causal_execution_diagnostics()
)
callback_diagnostics = (
callback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(direct_diagnostics["directEquationReadersConfigured"])
self.assertEqual(direct_diagnostics["directEffortAnchorCount"], 1)
self.assertFalse(
callback_diagnostics["directEquationReadersConfigured"]
)
self.assertEqual(callback_diagnostics["directEffortAnchorCount"], 0)
def test_inherited_direct_equation_reader_does_not_opt_in_subclass(
self,
) -> None:
system = _system_with_pnl0001_class(
_InheritedDirectReaderPnl0001,
direct_equation_readers=True,
)
pipe = system.network.components["pnl_1"]
solver = system.pressure_flow_solver
self.assertIsInstance(pipe, _InheritedDirectReaderPnl0001)
self.assertEqual(
solver.causal_execution_diagnostics()["directEffortAnchorCount"],
0,
)
anchor = next(
anchor
for group in solver._effort_groups["p"]
for anchor in group.anchors
if anchor.equation_id == "pnl_1:port_2_pressure_state"
)
self.assertIsNone(anchor.causal_evaluate)
def test_nonfinite_direct_effort_reader_uses_authoritative_fallback(
self,
) -> None:
system = _system_with_pnl0001_class(
_NonfiniteDirectReaderPnl0001,
executor_v2=True,
direct_equation_readers=True,
)
pipe = system.network.components["pnl_1"]
solver = system.pressure_flow_solver
self.assertIsInstance(pipe, _NonfiniteDirectReaderPnl0001)
self.assertEqual(
solver.causal_execution_diagnostics()["directEffortAnchorCount"],
1,
)
derivative = system.rhs(0.0, system.initial_state_vector())
self.assertTrue(all(value == value for value in derivative))
diagnostics = solver.causal_execution_diagnostics()
self.assertFalse(diagnostics["enabled"])
self.assertEqual(
diagnostics["disabledReason"],
"nonFiniteCausalEffortAnchor",
)
self.assertGreaterEqual(diagnostics["legacyFallbackCount"], 1)
self.assertGreater(pipe.direct_equation_evaluation_count, 0)
self.assertGreater(pipe.full_equation_evaluation_count, 0)
pipe.direct_equation_evaluation_count = 0
pipe.full_equation_evaluation_count = 0
solver.propagate_equal_efforts(("p",))
self.assertEqual(pipe.direct_equation_evaluation_count, 0)
self.assertEqual(pipe.full_equation_evaluation_count, 1)
def test_direct_sum_keeps_same_module_subclass_on_callback_plan(self) -> None:
original_spec = get_component_model_spec("amesim_pnvo001")
custom_spec = ComponentModelSpec(
component_class=_UntrustedPnvo001,
library=original_spec.library,
)
def custom_spec_lookup(model_type: str):
if model_type == "amesim_pnvo001":
return custom_spec
return get_component_model_spec(model_type)
with patch(
"app.simulation.registry.get_component_model_spec",
side_effect=custom_spec_lookup,
):
system = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
solver = system.pressure_flow_solver
valve = system.network.components["valve_1"]
self.assertIsInstance(valve, _UntrustedPnvo001)
assignment = next(
assignment
for stage in solver._explicit_flow_plan
for assignment in stage.assignments
if assignment.equation_id == "valve_1:mass_flow_balance"
)
self.assertIsNone(assignment.evaluate)
self.assertIs(assignment.component, valve)
self.assertEqual(
solver.causal_execution_diagnostics()[
"directSumFlowAssignmentCount"
],
0,
)
valve.pressure_flow_callback_count = 0
system.rhs(0.041, system.initial_state_vector())
self.assertGreater(valve.pressure_flow_callback_count, 0)
def test_direct_sum_assignments_match_disabled_path_bitwise(self) -> None:
direct = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
callback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=False,
)
direct_state = direct.initial_state_vector()
callback_state = callback.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
direct.rhs(time, direct_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in direct.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
direct_diagnostics = (
direct.pressure_flow_solver.causal_execution_diagnostics()
)
callback_diagnostics = (
callback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(
direct_diagnostics["directSumAssignmentsConfigured"]
)
self.assertFalse(
callback_diagnostics["directSumAssignmentsConfigured"]
)
self.assertGreater(
direct_diagnostics["directSumFlowAssignmentCount"],
0,
)
self.assertEqual(
callback_diagnostics["directSumFlowAssignmentCount"],
0,
)
self.assertEqual(
direct_diagnostics["compiledFlowAssignmentCount"],
callback_diagnostics["compiledFlowAssignmentCount"],
)
self.assertLess(
direct_diagnostics["directSumFlowAssignmentCount"],
direct_diagnostics["compiledFlowAssignmentCount"],
)
for key in (
"fastSolveCount",
"fullResidualAuditCount",
"auditFailureCount",
"legacyFallbackCount",
):
self.assertEqual(direct_diagnostics[key], callback_diagnostics[key])
self.assertGreater(direct_diagnostics["fastSolveCount"], 0)
self.assertEqual(direct_diagnostics["auditFailureCount"], 0)
self.assertEqual(direct_diagnostics["legacyFallbackCount"], 0)
def test_direct_sum_reader_rejection_uses_safe_component_fallback(
self,
) -> None:
callback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=False,
)
with patch.object(
PressureFlowSolver,
"_sum_to_zero_flow_target_reader",
side_effect=ValueError("synthetic unsupported sum"),
):
fallback = _system(
high_pressure_helium_step_project(),
direct_sum_assignments=True,
)
callback_state = callback.initial_state_vector()
fallback_state = fallback.initial_state_vector()
for time in (0.0, 0.041, 0.8):
self.assertEqual(
fallback.rhs(time, fallback_state),
callback.rhs(time, callback_state),
)
self.assertEqual(
tuple(
unknown.read()
for unknown in fallback.pressure_flow_solver.unknowns
),
tuple(
unknown.read()
for unknown in callback.pressure_flow_solver.unknowns
),
)
diagnostics = (
fallback.pressure_flow_solver.causal_execution_diagnostics()
)
self.assertTrue(diagnostics["directSumAssignmentsConfigured"])
self.assertEqual(diagnostics["directSumFlowAssignmentCount"], 0)
self.assertTrue(diagnostics["eligible"])
self.assertTrue(diagnostics["enabled"])
self.assertGreater(diagnostics["fastSolveCount"], 0)
self.assertEqual(diagnostics["auditFailureCount"], 0)
self.assertEqual(diagnostics["legacyFallbackCount"], 0)
def test_compiled_v2_is_enabled_by_default_and_can_be_disabled(self) -> None:
default = _system(zero_force_mass_project(), executor_v2=None)
disabled = _system(zero_force_mass_project(), executor_v2=False)
+56
View File
@@ -110,6 +110,62 @@ class TestMql8StaticRegressionTests(unittest.TestCase):
expected["hasMechanicalStateEvents"],
)
def test_contact_aware_mechanical_tolerance_plan_is_proof_gated(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(
compile_system_xml_network(self.validation.document)
)
reducer = system.mechanical_state_reducer
plan = reducer.absolute_tolerance_plan(
1.0e-8,
mode="contact-aware-v1",
)
diagnostics = plan.as_dict()
self.assertEqual(len(plan.values), len(system.initial_state_vector()))
self.assertEqual(diagnostics["groupCount"], 10)
self.assertEqual(diagnostics["eligibleGroupCount"], 8)
self.assertEqual(diagnostics["relaxedVelocityStateCount"], 8)
self.assertEqual(diagnostics["relaxedPositionStateCount"], 0)
by_component = {
group.components[0]: group
for group in plan.groups
}
for index in range(1, 9):
group = by_component[f"amesim_mecmas21_{index}"]
self.assertTrue(group.eligible)
self.assertEqual(group.reason, "eligibleFlexibleContact")
self.assertAlmostEqual(group.velocity_atol, 1.0e-10)
self.assertEqual(group.position_atol, 1.0e-12)
self.assertAlmostEqual(
group.minimum_damping_strength_ratio,
10.0,
)
self.assertAlmostEqual(
group.minimum_force_limited_velocity_atol,
1.0e-10,
)
self.assertEqual(
group.contacts,
(f"amesim_lstp00a_{index}",),
)
for index in (9, 10):
group = by_component[f"amesim_mecmas21_{index}"]
self.assertFalse(group.eligible)
self.assertEqual(group.reason, "discreteEndstop")
self.assertEqual(group.velocity_atol, 1.0e-12)
self.assertEqual(group.position_atol, 1.0e-12)
legacy = reducer.absolute_tolerance_plan(1.0e-8, mode="legacy")
self.assertTrue(
all(
group.velocity_atol == 1.0e-12
and group.position_atol == 1.0e-12
for group in legacy.groups
)
)
def test_signal_event_schedule_is_fixed_for_every_horizon(self) -> None:
assert self.validation.document is not None
system = GenericFluidSystem(