From 3b38f73fe09699bf66f9ee65a129b0dd1bb885fc Mon Sep 17 00:00:00 2001 From: ljz <425868052@qq.com> Date: Thu, 10 Sep 2026 01:12:18 +0800 Subject: [PATCH] Replace Python numerical kernels with native C execution --- .gitattributes | 2 + .gitignore | 4 + README.md | 13 +- app/main.py | 238 +- app/simulation/README.md | 412 +- app/simulation/backends.py | 37 + app/simulation/benchmark_performance.py | 244 - app/simulation/benchmark_regression.py | 2468 ---- .../components/amesim/boundary/sources.py | 53 +- .../components/amesim/flow/orifices.py | 1007 +- .../components/amesim/flow/pipes.py | 1915 +-- .../components/amesim/junctions/nodes.py | 217 +- .../components/amesim/mechanical/pistons.py | 287 +- .../amesim/mechanical/translational.py | 1548 +-- .../components/amesim/media/mediums.py | 498 +- .../components/amesim/signals/sources.py | 362 +- .../components/amesim/storage/chambers.py | 670 +- app/simulation/components/example.md | 288 +- .../components/experimental/flow/orifice.py | 113 +- .../components/experimental/flow/pipe.py | 10 - .../experimental/flow/resistive_pipe.py | 179 +- .../components/experimental/junctions/tee.py | 262 +- .../experimental/storage/cylinder.py | 154 +- .../components/experimental/storage/tank.py | 154 +- app/simulation/config.py | 148 + app/simulation/core/base.py | 295 +- app/simulation/core/equations.py | 7 +- app/simulation/core/medium.py | 370 +- app/simulation/core/peng_robinson.py | 424 - app/simulation/core/state.py | 21 - app/simulation/examples/__init__.py | 1 - app/simulation/examples/test_mql/__init__.py | 25 - app/simulation/examples/test_mql/baseline.py | 77 - app/simulation/examples/test_mql/closure.py | 4213 ------- app/simulation/examples/test_mql/computed.py | 468 - app/simulation/examples/test_mql/config.py | 151 - .../examples/test_mql/line_parameters.py | 451 - app/simulation/examples/test_mql/lines.py | 102 - .../examples/test_mql/mechanical.py | 544 - app/simulation/examples/test_mql/nodes.py | 215 - app/simulation/examples/test_mql/pneumatic.py | 273 - .../examples/test_mql/pneumatic_lines.py | 194 - .../examples/test_mql/pneumatic_topology.py | 128 - .../examples/test_mql/primitives/__init__.py | 1 - .../test_mql/primitives/mechanical.py | 168 - .../examples/test_mql/primitives/pneumatic.py | 437 - .../test_mql/primitives/pneumatic_lines.py | 881 -- app/simulation/examples/test_mql/run.py | 100 - .../examples/test_mql/run_baseline.py | 80 - .../test_mql/run_full_state_comparison.py | 4621 ------- app/simulation/examples/test_mql/solver.py | 18 - .../examples/test_mql/structural_network.py | 78 - app/simulation/examples/test_mql/system.py | 6334 ---------- app/simulation/examples/test_mql/topology.py | 118 - app/simulation/examples/testmodel/__init__.py | 1 - app/simulation/examples/testmodel/closure.py | 668 - .../examples/testmodel/dynamic_pipe.py | 272 - app/simulation/examples/testmodel/run.py | 222 - app/simulation/examples/testmodel/system.py | 303 - app/simulation/max_step_matrix.py | 1247 -- app/simulation/native_codegen/__init__.py | 1 + app/simulation/native_codegen/__main__.py | 82 + app/simulation/native_codegen/build.py | 119 + app/simulation/native_codegen/compiler.py | 388 + app/simulation/native_codegen/contracts.py | 32 + app/simulation/native_codegen/extended.py | 561 + app/simulation/native_codegen/input.py | 90 + app/simulation/native_codegen/runner.py | 128 + app/simulation/performance.py | 625 +- app/simulation/physical_state_v21.py | 1496 --- app/simulation/property_cache.py | 234 - app/simulation/reporting/__init__.py | 24 +- app/simulation/reporting/pnl0002_replay.py | 263 - .../test_mql_chamber_observations.py | 195 - .../reporting/test_mql_comparison.py | 274 - .../reporting/test_mql_line_observations.py | 211 - .../test_mql_mechanical_observations.py | 395 - .../reporting/test_mql_observations.py | 210 - .../test_mql_orifice_observations.py | 194 - .../reporting/test_mql_output_schema.py | 100 - .../reporting/test_mql_output_validation.py | 161 - .../reporting/test_mql_variables.py | 111 - app/simulation/reporting/testmodel_outputs.py | 393 - app/simulation/results.py | 49 + app/simulation/sampling.py | 101 + app/simulation/solvers/__init__.py | 1 - app/simulation/solvers/algebraic.py | 3332 ----- app/simulation/solvers/algebraic_blocks.py | 1481 --- app/simulation/solvers/causal_ir.py | 794 -- app/simulation/solvers/jacobian.py | 1478 --- app/simulation/solvers/mechanical.py | 1037 -- app/simulation/solvers/pneumatic_storage.py | 256 - app/simulation/solvers/pneumatic_volume.py | 121 - app/simulation/solvers/signal.py | 145 - app/simulation/solvers/solver.py | 1673 --- app/simulation/solvers/stream.py | 204 - app/simulation/solvers/tangent.py | 1007 -- app/simulation/solvers/thermofluid.py | 567 - app/simulation/systems/generic.py | 1824 --- app/simulation/systems/network.py | 74 +- app/simulation/warmup.py | 74 +- app/system_xml.py | 6 +- bat/start-backend.bat | 1 + bat/start-backend.sh | 1 + constraints/python312-direct.txt | 2 - constraints/python312-linux-x86_64.lock | 2 - docs/other/C内核第一版实施记录.md | 66 + docs/other/C内核组件库覆盖记录.md | 63 + .../C语言数值内核实施计划与可行性评估.md | 135 + docs/other/Python数值实现退役记录.md | 26 + .../component-model-authoring-spec-v1.md | 255 +- docs/update-log/更新日志-2026-09-09.md | 23 + docs/update-log/更新日志-2026-09-10.md | 15 + frontend/src/App.tsx | 22 +- frontend/src/SimulationResultsView.tsx | 4 +- frontend/tests/e2e/native-result.spec.ts | 42 + native/README.md | 76 + native/THIRD_PARTY_NOTICES.txt | 89 + native/components/kernels.c | 289 + native/include/kernels.h | 37 + native/include/runtime.h | 34 + native/runtime/common.c | 158 + native/runtime/cvode_solver.c | 97 + native/runtime/main.c | 119 + native/runtime/rk45.c | 119 + requirements-test.txt | 2 + requirements.txt | 2 - .../simulation/test_mql_8/manifest.json | 10 +- .../test_mql_8/sources/test-mql-8.json | 10204 ++++++++++++++++ .../test_mql_8/sources/test-mql-8.xml | 2078 ++++ tests/data/native-python-reference.json | 1 + tests/data/native-skill-test.xml | 325 + tests/data/test-mql-8.json | 90 +- tests/data/test-mql-8.xml | 2 +- tests/native_reference.py | 30 + .../test_amesim_component_migration_matrix.py | 38 - tests/test_amesim_gas_registry.py | 18 +- tests/test_amesim_helium_medium.py | 287 - tests/test_amesim_helium_step_long_run.py | 9 - tests/test_amesim_mechanical_components.py | 98 - ...est_amesim_mechanical_public_components.py | 141 - tests/test_amesim_mechanical_xml.py | 141 - tests/test_amesim_pnch012_component.py | 98 - tests/test_amesim_pnch023_component.py | 53 - tests/test_amesim_pneumatic_components.py | 253 - .../test_amesim_pneumatic_line_components.py | 157 - .../test_amesim_pneumatic_node_components.py | 122 - tests/test_amesim_pnl0001_component.py | 325 - tests/test_amesim_pnl0001_pipe.py | 147 - .../test_amesim_pnl0002_pnl0003_component.py | 479 - tests/test_amesim_pnl00r_component.py | 683 -- tests/test_amesim_pnor001_component.py | 50 - tests/test_amesim_pnpl01_component.py | 16 - tests/test_amesim_pnrp17_xml.py | 38 - tests/test_amesim_pnvo001_fixed_component.py | 238 - tests/test_amesim_pnvo001_signal_xml.py | 2 - tests/test_amesim_signal_components.py | 196 - tests/test_amesim_ud00_xml.py | 1 - tests/test_analytic_tangent_primitives.py | 474 - tests/test_benchmark_regression.py | 949 -- tests/test_causal_numeric_ir.py | 411 - tests/test_component_interfaces.py | 42 +- tests/test_component_metadata.py | 10 - tests/test_contact_solver_causalization.py | 218 - tests/test_core_solver.py | 1577 --- tests/test_dependency_constraints.py | 4 - tests/test_generic_jacobian_sparsity.py | 306 - tests/test_generic_system_xml_simulation.py | 244 +- .../test_ideal_pneumatic_storage_coupling.py | 58 - tests/test_max_step_matrix.py | 333 - tests/test_mechanical_solver_causalization.py | 716 -- tests/test_mql_full_branches_regression.py | 177 - tests/test_native_catalog.py | 113 + tests/test_native_codegen.py | 117 + tests/test_native_only_backend.py | 39 + tests/test_peng_robinson.py | 110 - tests/test_performance_benchmark.py | 70 - tests/test_physical_state_v21.py | 487 - tests/test_pnl0002_replay.py | 108 - tests/test_pressure_flow_causal_execution.py | 890 -- ...st_pressure_flow_solver_equation_blocks.py | 287 - ...est_pressure_flow_solver_initialization.py | 506 - tests/test_pressure_flow_solver_sparsity.py | 210 - ...st_property_performance_instrumentation.py | 146 - tests/test_run_test_mql_baseline.py | 88 - ...test_run_test_mql_full_state_comparison.py | 1024 -- tests/test_simulation_paths.py | 31 - tests/test_simulation_performance.py | 135 +- tests/test_simulation_performance_pipeline.py | 242 - tests/test_simulation_property_cache.py | 214 - tests/test_simulation_safety.py | 43 +- tests/test_simulation_warmup.py | 61 +- tests/test_sparse_secant_jacobian.py | 950 -- tests/test_stream_pressure_block_solver.py | 634 - tests/test_stream_resolver_execution_plan.py | 244 - tests/test_supported_piston_tangent.py | 289 - tests/test_test_mql_8_regression.py | 387 - tests/test_test_mql_ame_contract.py | 4 +- tests/test_test_mql_baseline.py | 50 - tests/test_test_mql_branch_simulation.py | 130 - tests/test_test_mql_chamber_observations.py | 112 - tests/test_test_mql_chamber_segment.py | 92 - tests/test_test_mql_closure.py | 138 - tests/test_test_mql_comparison.py | 169 - tests/test_test_mql_computed.py | 272 - tests/test_test_mql_config.py | 70 - tests/test_test_mql_example_runner.py | 63 - tests/test_test_mql_line_observations.py | 111 - tests/test_test_mql_lines.py | 72 - tests/test_test_mql_mechanical.py | 297 - .../test_test_mql_mechanical_observations.py | 189 - tests/test_test_mql_nodes.py | 245 - tests/test_test_mql_observations.py | 95 - tests/test_test_mql_orifice_observations.py | 110 - tests/test_test_mql_output_schema.py | 91 - tests/test_test_mql_output_validation.py | 181 - tests/test_test_mql_pneumatic.py | 137 - tests/test_test_mql_pnl0001.py | 115 - tests/test_test_mql_pnl0001_segment.py | 1564 --- tests/test_test_mql_pnl0002.py | 92 - tests/test_test_mql_pnl0003_pnl00r.py | 94 - tests/test_test_mql_structural.py | 117 - tests/test_test_mql_topology.py | 45 - tests/test_test_mql_variables.py | 72 - tests/test_thermofluid_closure_plan.py | 642 - tests/test_thermofluid_recovery.py | 339 - tests/test_three_piston_tangent.py | 337 - 227 files changed, 16801 insertions(+), 75499 deletions(-) create mode 100644 app/simulation/backends.py delete mode 100644 app/simulation/benchmark_performance.py delete mode 100644 app/simulation/benchmark_regression.py delete mode 100644 app/simulation/components/experimental/flow/pipe.py create mode 100644 app/simulation/config.py delete mode 100644 app/simulation/core/peng_robinson.py delete mode 100644 app/simulation/core/state.py delete mode 100644 app/simulation/examples/__init__.py delete mode 100644 app/simulation/examples/test_mql/__init__.py delete mode 100644 app/simulation/examples/test_mql/baseline.py delete mode 100644 app/simulation/examples/test_mql/closure.py delete mode 100644 app/simulation/examples/test_mql/computed.py delete mode 100644 app/simulation/examples/test_mql/config.py delete mode 100644 app/simulation/examples/test_mql/line_parameters.py delete mode 100644 app/simulation/examples/test_mql/lines.py delete mode 100644 app/simulation/examples/test_mql/mechanical.py delete mode 100644 app/simulation/examples/test_mql/nodes.py delete mode 100644 app/simulation/examples/test_mql/pneumatic.py delete mode 100644 app/simulation/examples/test_mql/pneumatic_lines.py delete mode 100644 app/simulation/examples/test_mql/pneumatic_topology.py delete mode 100644 app/simulation/examples/test_mql/primitives/__init__.py delete mode 100644 app/simulation/examples/test_mql/primitives/mechanical.py delete mode 100644 app/simulation/examples/test_mql/primitives/pneumatic.py delete mode 100644 app/simulation/examples/test_mql/primitives/pneumatic_lines.py delete mode 100644 app/simulation/examples/test_mql/run.py delete mode 100644 app/simulation/examples/test_mql/run_baseline.py delete mode 100644 app/simulation/examples/test_mql/run_full_state_comparison.py delete mode 100644 app/simulation/examples/test_mql/solver.py delete mode 100644 app/simulation/examples/test_mql/structural_network.py delete mode 100644 app/simulation/examples/test_mql/system.py delete mode 100644 app/simulation/examples/test_mql/topology.py delete mode 100644 app/simulation/examples/testmodel/__init__.py delete mode 100644 app/simulation/examples/testmodel/closure.py delete mode 100644 app/simulation/examples/testmodel/dynamic_pipe.py delete mode 100644 app/simulation/examples/testmodel/run.py delete mode 100644 app/simulation/examples/testmodel/system.py delete mode 100644 app/simulation/max_step_matrix.py create mode 100644 app/simulation/native_codegen/__init__.py create mode 100644 app/simulation/native_codegen/__main__.py create mode 100644 app/simulation/native_codegen/build.py create mode 100644 app/simulation/native_codegen/compiler.py create mode 100644 app/simulation/native_codegen/contracts.py create mode 100644 app/simulation/native_codegen/extended.py create mode 100644 app/simulation/native_codegen/input.py create mode 100644 app/simulation/native_codegen/runner.py delete mode 100644 app/simulation/physical_state_v21.py delete mode 100644 app/simulation/property_cache.py delete mode 100644 app/simulation/reporting/pnl0002_replay.py delete mode 100644 app/simulation/reporting/test_mql_chamber_observations.py delete mode 100644 app/simulation/reporting/test_mql_comparison.py delete mode 100644 app/simulation/reporting/test_mql_line_observations.py delete mode 100644 app/simulation/reporting/test_mql_mechanical_observations.py delete mode 100644 app/simulation/reporting/test_mql_observations.py delete mode 100644 app/simulation/reporting/test_mql_orifice_observations.py delete mode 100644 app/simulation/reporting/test_mql_output_schema.py delete mode 100644 app/simulation/reporting/test_mql_output_validation.py delete mode 100644 app/simulation/reporting/test_mql_variables.py delete mode 100644 app/simulation/reporting/testmodel_outputs.py create mode 100644 app/simulation/results.py create mode 100644 app/simulation/sampling.py delete mode 100644 app/simulation/solvers/__init__.py delete mode 100644 app/simulation/solvers/algebraic.py delete mode 100644 app/simulation/solvers/algebraic_blocks.py delete mode 100644 app/simulation/solvers/causal_ir.py delete mode 100644 app/simulation/solvers/jacobian.py delete mode 100644 app/simulation/solvers/mechanical.py delete mode 100644 app/simulation/solvers/pneumatic_storage.py delete mode 100644 app/simulation/solvers/pneumatic_volume.py delete mode 100644 app/simulation/solvers/signal.py delete mode 100644 app/simulation/solvers/solver.py delete mode 100644 app/simulation/solvers/stream.py delete mode 100644 app/simulation/solvers/tangent.py delete mode 100644 app/simulation/solvers/thermofluid.py delete mode 100644 app/simulation/systems/generic.py create mode 100644 docs/other/C内核第一版实施记录.md create mode 100644 docs/other/C内核组件库覆盖记录.md create mode 100644 docs/other/Python数值实现退役记录.md create mode 100644 docs/update-log/更新日志-2026-09-09.md create mode 100644 docs/update-log/更新日志-2026-09-10.md create mode 100644 frontend/tests/e2e/native-result.spec.ts create mode 100644 native/README.md create mode 100644 native/THIRD_PARTY_NOTICES.txt create mode 100644 native/components/kernels.c create mode 100644 native/include/kernels.h create mode 100644 native/include/runtime.h create mode 100644 native/runtime/common.c create mode 100644 native/runtime/cvode_solver.c create mode 100644 native/runtime/main.c create mode 100644 native/runtime/rk45.c create mode 100644 requirements-test.txt create mode 100644 tests/baselines/simulation/test_mql_8/sources/test-mql-8.json create mode 100644 tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml create mode 100644 tests/data/native-python-reference.json create mode 100644 tests/data/native-skill-test.xml create mode 100644 tests/native_reference.py delete mode 100644 tests/test_amesim_component_migration_matrix.py delete mode 100644 tests/test_amesim_helium_medium.py delete mode 100644 tests/test_amesim_mechanical_components.py delete mode 100644 tests/test_amesim_pneumatic_components.py delete mode 100644 tests/test_amesim_pneumatic_line_components.py delete mode 100644 tests/test_amesim_pnl0001_pipe.py delete mode 100644 tests/test_amesim_pnl00r_component.py delete mode 100644 tests/test_amesim_signal_components.py delete mode 100644 tests/test_analytic_tangent_primitives.py delete mode 100644 tests/test_benchmark_regression.py delete mode 100644 tests/test_causal_numeric_ir.py delete mode 100644 tests/test_contact_solver_causalization.py delete mode 100644 tests/test_core_solver.py delete mode 100644 tests/test_generic_jacobian_sparsity.py delete mode 100644 tests/test_ideal_pneumatic_storage_coupling.py delete mode 100644 tests/test_max_step_matrix.py delete mode 100644 tests/test_mechanical_solver_causalization.py delete mode 100644 tests/test_mql_full_branches_regression.py create mode 100644 tests/test_native_catalog.py create mode 100644 tests/test_native_codegen.py create mode 100644 tests/test_native_only_backend.py delete mode 100644 tests/test_peng_robinson.py delete mode 100644 tests/test_performance_benchmark.py delete mode 100644 tests/test_physical_state_v21.py delete mode 100644 tests/test_pnl0002_replay.py delete mode 100644 tests/test_pressure_flow_causal_execution.py delete mode 100644 tests/test_pressure_flow_solver_equation_blocks.py delete mode 100644 tests/test_pressure_flow_solver_initialization.py delete mode 100644 tests/test_pressure_flow_solver_sparsity.py delete mode 100644 tests/test_property_performance_instrumentation.py delete mode 100644 tests/test_run_test_mql_baseline.py delete mode 100644 tests/test_run_test_mql_full_state_comparison.py delete mode 100644 tests/test_simulation_paths.py delete mode 100644 tests/test_simulation_performance_pipeline.py delete mode 100644 tests/test_simulation_property_cache.py delete mode 100644 tests/test_sparse_secant_jacobian.py delete mode 100644 tests/test_stream_pressure_block_solver.py delete mode 100644 tests/test_stream_resolver_execution_plan.py delete mode 100644 tests/test_supported_piston_tangent.py delete mode 100644 tests/test_test_mql_8_regression.py delete mode 100644 tests/test_test_mql_baseline.py delete mode 100644 tests/test_test_mql_branch_simulation.py delete mode 100644 tests/test_test_mql_chamber_observations.py delete mode 100644 tests/test_test_mql_chamber_segment.py delete mode 100644 tests/test_test_mql_closure.py delete mode 100644 tests/test_test_mql_comparison.py delete mode 100644 tests/test_test_mql_computed.py delete mode 100644 tests/test_test_mql_config.py delete mode 100644 tests/test_test_mql_example_runner.py delete mode 100644 tests/test_test_mql_line_observations.py delete mode 100644 tests/test_test_mql_lines.py delete mode 100644 tests/test_test_mql_mechanical.py delete mode 100644 tests/test_test_mql_mechanical_observations.py delete mode 100644 tests/test_test_mql_nodes.py delete mode 100644 tests/test_test_mql_observations.py delete mode 100644 tests/test_test_mql_orifice_observations.py delete mode 100644 tests/test_test_mql_output_schema.py delete mode 100644 tests/test_test_mql_output_validation.py delete mode 100644 tests/test_test_mql_pneumatic.py delete mode 100644 tests/test_test_mql_pnl0001.py delete mode 100644 tests/test_test_mql_pnl0001_segment.py delete mode 100644 tests/test_test_mql_pnl0002.py delete mode 100644 tests/test_test_mql_pnl0003_pnl00r.py delete mode 100644 tests/test_test_mql_structural.py delete mode 100644 tests/test_test_mql_topology.py delete mode 100644 tests/test_test_mql_variables.py delete mode 100644 tests/test_thermofluid_closure_plan.py delete mode 100644 tests/test_thermofluid_recovery.py delete mode 100644 tests/test_three_piston_tangent.py diff --git a/.gitattributes b/.gitattributes index 4c28bac..07d1f09 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,5 @@ tests/data/test-mql-8.xml text eol=lf tests/data/test-mql-8.json text eol=lf tests/data/test_mql-full-branches-01-04.xml text eol=lf tests/baselines/simulation/**/*.json text eol=lf + +tests/baselines/simulation/**/sources/* text eol=lf diff --git a/.gitignore b/.gitignore index 3e8914e..0e333e9 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ htmlcov/ # Local Linux toolchain (downloaded for the startup scripts) .tools/node-*-linux-x64/ +.tools/node-*-win-x64/ + +# Local benchmark archives, generated executables and comparison outputs +/test/ app/data/ frontend/node_modules/ diff --git a/README.md b/README.md index beef40b..a014a55 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ReactFlow 系统建模与 `app.simulation` 仿真后端。 ## 开发环境准备 -后端统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本 +后端编排层统一使用 Python 3.12;仓库根目录的 `.python-version` 记录本轮参考补丁版本 `3.12.3`。`requirements.txt` 保留支持范围, `constraints/python312-direct.txt` 固定跨平台开发环境的直接依赖参考版本; `constraints/python312-linux-x86_64.lock` 则完整固定发布与 CI 所用的 Linux x86_64 @@ -84,6 +84,10 @@ Linux: 后端地址为 `http://127.0.0.1:8000`,前端地址为 `http://127.0.0.1:5173`。Windows 的 `start-all.bat` 会分别打开两个命令行窗口;Linux 的 `start-all.sh` 会在同一终端管理两个进程,按 `Ctrl+C` 会同时停止它们。 +网页的 System XML 仿真默认使用 C 内核(`native`);后端入口和启动脚本使用同一默认值,不需要每次手动设置环境变量。启动日志显示 `Simulation numeric engine: native`,启动预热仅检查 C 工具链和 XML Schema,不执行 Python/SciPy 求解器预热。模型专用 EXE 在提交模型时生成或从缓存复用。 + +当前 C 构建支持 Windows x64,已覆盖组件库当前注册的 27 类模型(22 类 Amesim、5 类实验组件)。具体公式范围与连接限制见[原生后端说明](native/README.md);不支持的自定义模型或未收敛的连接会明确报错,不自动切回 Python。旧 Python 数值后端已删除;Linux 暂支持编辑、校验与生成,原生构建打包尚待实现。旧固定拓扑示例接口返回 HTTP 410,请改用统一 XML 接口。 + ## 后端接口 - `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。 @@ -99,7 +103,7 @@ Linux: 气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。 -当前网络层可按端口域处理气动压力-流量残差与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡,并使用 SciPy 完成非线性代数闭合和时间积分。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点,同时支持已登记的信号和机械基础件。它不是完整 DAE 或事件求解器,也不等价于严格 Modelica.Fluid 实现。 +当前网络层按端口域处理气动压力/流量与 stream 焓、标量信号传播,以及一维机械 `x/v` 等值和 `f` 平衡。默认 C 后端在生成的 EXE 内完成连接闭合、RK45/CVODE BDF 积分及信号/限位事件。通用仿真仍有已声明的拓扑和物理公式范围,并不等价于完整 Amesim 或 Modelica.Fluid 实现。 XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `requirements.txt` 中。 @@ -112,3 +116,8 @@ XML 解析依赖 `lxml` 执行本地 XSD 校验,该依赖已包含在 `require - [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json) - [System XML v3 协议(当前规范)](docs/standard/system-xml-v3.md) - [System XML v3 XSD(当前 Schema)](schemas/system-simulation-v3.xsd) + + +旧 Python 积分器、模型数值公式及物性缓存已移除。公共配置、进度和采样校验分别位于 `app/simulation/config.py` 与 `sampling.py`;模型 Python 文件仅保留参数、端口、结果和方程结构声明。质量/能量初值也由 C 计算。 + +后端运行依赖不再包含 NumPy/SciPy;运行测试请安装 `requirements-test.txt`。Windows C 回归需要配置 GCC 与 SUNDIALS,见 [C 后端说明](native/README.md)。删除范围和验证见 [Python 数值实现退役记录](docs/other/Python数值实现退役记录.md)。 diff --git a/app/main.py b/app/main.py index 1c86aed..8b37873 100644 --- a/app/main.py +++ b/app/main.py @@ -23,8 +23,7 @@ from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse from pydantic import BaseModel, ConfigDict, Field, ValidationError from app.simulation.performance import performance_span, profile_phase, profile_run -from app.simulation.property_cache import property_cache_run -from app.simulation.solvers.solver import SolverActivityTracker +from app.simulation.config import SolverActivityTracker from app.system_xml import ( SystemXmlDocument, SystemXmlValidationReport, @@ -41,8 +40,15 @@ if TYPE_CHECKING: @asynccontextmanager async def _app_lifespan(application: FastAPI) -> AsyncIterator[None]: + import logging + + from app.simulation.backends import numeric_engine_name from app.simulation.warmup import warm_up_simulation_runtime + application.state.simulation_numeric_engine = numeric_engine_name() + logging.getLogger("uvicorn.error").info( + "Simulation numeric engine: %s", application.state.simulation_numeric_engine, + ) application.state.simulation_warmup = warm_up_simulation_runtime().as_dict() yield @@ -688,26 +694,15 @@ def run_system_xml_simulation( cancel_check: Callable[[], bool] | None = None, activity_tracker: SolverActivityTracker | None = None, ) -> dict[str, object]: - with property_cache_run() as property_cache: - with profile_run() as trace: - result = _run_system_xml_simulation_profiled( - xml_bytes, - progress_callback, - cancel_check, - activity_tracker, - ) + with profile_run() as trace: + result = _run_system_xml_simulation_profiled( + xml_bytes, + progress_callback, + cancel_check, + activity_tracker, + ) performance = trace.snapshot() - if performance.get("mode") == "audit" and property_cache is not None: - cache_info = property_cache.info() - performance["propertyCache"] = { - "hits": cache_info.hits, - "misses": cache_info.misses, - "maxEntriesPerCache": cache_info.max_entries_per_cache, - "cacheCount": cache_info.cache_count, - "currentEntries": cache_info.current_entries, - "evictions": cache_info.evictions, - } if performance.get("mode") != "off": diagnostics = dict(result.get("diagnostics", {})) diagnostics["performance"] = performance @@ -721,13 +716,8 @@ def _run_system_xml_simulation_profiled( cancel_check: Callable[[], bool] | None = None, activity_tracker: SolverActivityTracker | None = None, ) -> dict[str, object]: - from app.simulation.solvers.algebraic import AlgebraicSolveError - from app.simulation.solvers.solver import SolveIVPConfig - from app.simulation.solvers.stream import StreamSolveError - from app.simulation.systems.generic import ( - GenericFluidSystem, - SimulationPreparationError, - ) + from app.simulation.backends import simulate_network + from app.simulation.results import SimulationPreparationError def emit( progress: int, @@ -766,20 +756,9 @@ def _run_system_xml_simulation_profiled( ) try: - system = GenericFluidSystem(network) - result = system.simulate( - SolveIVPConfig( - t_start=document.simulation.t_start, - t_stop=document.simulation.t_stop, - method=document.simulation.method, - # The pressure-flow closure is solved to a scaled 1e-7 - # residual. State-specific mechanical absolute tolerances now - # keep ideal-stop Jacobian perturbations stable, so the outer - # integrator can use its canonical 1e-6 relative accuracy. - rtol=1.0e-6, - max_step=document.simulation.max_step, - ), - sample_step=document.simulation.sample_step, + result = simulate_network( + network, + document.simulation, progress_callback=report_system_progress, cancel_check=cancel_check, activity_tracker=activity_tracker, @@ -799,41 +778,6 @@ def _run_system_xml_simulation_profiled( ], }, ) from exc - except AlgebraicSolveError as exc: - algebraic_diagnostics = exc.diagnostics.as_dict() - algebraic_diagnostics["scopeKind"] = exc.scope_kind - algebraic_diagnostics["scopeComponents"] = list(exc.scope_components) - raise HTTPException( - status_code=422, - detail={ - "message": str(exc), - "issues": [ - { - "severity": "error", - "layer": "simulation", - "code": "PRESSURE_FLOW_SOLVE_FAILED", - "message": str(exc), - } - ], - "diagnostics": algebraic_diagnostics, - }, - ) from exc - except StreamSolveError as exc: - raise HTTPException( - status_code=422, - detail={ - "message": str(exc), - "issues": [ - { - "severity": "error", - "layer": "simulation", - "code": "STREAM_SOLVE_FAILED", - "message": str(exc), - } - ], - "diagnostics": exc.diagnostics.as_dict(), - }, - ) from exc except (RuntimeError, ValueError) as exc: raise HTTPException( status_code=422, @@ -1524,143 +1468,15 @@ def sanitize_project_id(project_id: str) -> str: def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, object]: - from app.simulation.examples.testmodel.run import ( - TestModelExecutionConfig, - TestModelRunConfig, - TestModelSamplingConfig, - run_testmodel, - ) - from app.simulation.examples.testmodel.system import ( - BranchConfig, - CylinderConfig, - OrificeConfig, - PipeConfig, - TankConfig, - TestModelConfig, - ) - from app.simulation.solvers.solver import SolveIVPConfig - - validate_reactflow_execution_contract(project) - - nodes_by_type: dict[str, list[ReactFlowNodePayload]] = {} - for node in project.nodes: - nodes_by_type.setdefault(node.data.modelType, []).append(node) - - cylinder = first_node(nodes_by_type, "cylinder") - tank = first_node(nodes_by_type, "tank") - orifices = nodes_by_type.get("orifice", []) - pipes = nodes_by_type.get("pipe", []) - - model_config = TestModelConfig( - cylinder=CylinderConfig( - volume=parameter_float(cylinder, "volume", 0.01), - p0=parameter_float(cylinder, "p0", 35e6), - T0=parameter_float(cylinder, "T0", 300.0), - ), - upper_branch=BranchConfig( - orifice=OrificeConfig(K=parameter_float(node_at(orifices, 0), "K", 1e-5)), - pipe=pipe_config_from_node(node_at(pipes, 0), PipeConfig), - ), - lower_branch=BranchConfig( - orifice=OrificeConfig(K=parameter_float(node_at(orifices, 1), "K", 1e-5)), - pipe=pipe_config_from_node(node_at(pipes, 1), PipeConfig), - ), - tank=TankConfig( - volume=parameter_float(tank, "volume", 0.1), - p0=parameter_float(tank, "p0", 1e5), - T0=parameter_float(tank, "T0", 300.0), - ), - ) - run_config = TestModelRunConfig( - model=model_config, - solver=SolveIVPConfig( - t_start=project.simulation.t_start, - t_stop=project.simulation.t_stop, - method=project.simulation.method, - max_step=project.simulation.max_step, - ), - sampling=TestModelSamplingConfig(step=project.simulation.step), - execution=TestModelExecutionConfig(use_modelica_reference_if_available=False), - ) - result = run_testmodel(run_config=run_config) - series_keys = ("time", "mytank.p", "mytank.T", "mycylinder.p", "mycylinder.T") - series = { - key: [float(value) for value in result.series[key]] - for key in series_keys - if key in result.series - } - - return { - "success": bool(result.solution.success), - "message": str(result.solution.message), - "usedModelicaReference": result.used_modelica_reference, - "final": { - "time": series["time"][-1], - "tankPressure": series["mytank.p"][-1], - "tankTemperature": series["mytank.T"][-1], - "cylinderPressure": series["mycylinder.p"][-1], - "cylinderTemperature": series["mycylinder.T"][-1], - }, - "series": series, - "artifacts": { - "primaryCsv": str(result.artifacts.primary_csv_path), - "temperatureCsv": str(result.artifacts.temperature_csv_path), - "temperatureSvg": str(result.artifacts.temperature_svg_path), - "runReport": str(result.artifacts.run_report_path), - }, - "networkSummary": result.system.network.summary(), - } + raise HTTPException(status_code=410, detail="The legacy Python example runner has been removed. Export System XML and use /api/system-xml/simulate.") def run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object]: - from app.simulation.examples.test_mql.run import run_test_mql - from app.simulation.examples.test_mql.system import TestMqlRunConfig - - validate_reactflow_execution_contract(project) - - run_config = TestMqlRunConfig( - t_start=project.simulation.t_start, - t_stop=project.simulation.t_stop, - sample_step=project.simulation.step, - ) - result = run_test_mql(run_config=run_config) - series = { - key: [float(value) for value in values] - for key, values in result.result.series.items() - } - final = {key: values[-1] for key, values in series.items() if values} - snapshot = result.system.snapshot() - return { - "success": True, - "message": "test_mql fixed-topology simulation completed.", - "model": { - "name": snapshot.model_name, - "componentCount": snapshot.component_count, - "connectionCount": snapshot.connection_count, - "continuousStateCount": snapshot.continuous_state_count, - "discreteStateCount": snapshot.discrete_state_count, - }, - "final": final, - "series": series, - "artifacts": { - "summary": str(result.summary_path), - }, - } + raise HTTPException(status_code=410, detail="The legacy Python example runner has been removed. Export System XML and use /api/system-xml/simulate.") -def first_node( - nodes_by_type: dict[str, list[ReactFlowNodePayload]], - model_type: str, -) -> ReactFlowNodePayload | None: - nodes = nodes_by_type.get(model_type, []) - return nodes[0] if nodes else None -def node_at( - nodes: list[ReactFlowNodePayload], - index: int, -) -> ReactFlowNodePayload | None: - return nodes[index] if index < len(nodes) else None def parameter_float( @@ -1675,13 +1491,3 @@ def parameter_float( return float(value) except (TypeError, ValueError): raise ValueError(f"Parameter '{name}' on component '{node.id}' must be numeric.") - - -def pipe_config_from_node(node: ReactFlowNodePayload | None, pipe_config_type): - return pipe_config_type( - length=parameter_float(node, "length", 5.0), - diameter=parameter_float(node, "diameter", 0.02), - lambda_darcy=parameter_float(node, "lambda_darcy", 0.02), - p0=parameter_float(node, "p0", 1e5), - T0=parameter_float(node, "T0", 300.0), - ) diff --git a/app/simulation/README.md b/app/simulation/README.md index 6de5c25..d85da7c 100644 --- a/app/simulation/README.md +++ b/app/simulation/README.md @@ -1,407 +1,17 @@ # 仿真后端 -`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。 +当前采用 Python 编排、C 数值执行。输入 XML 经校验后,Python 根据模型参数和连接生成系统专用 C;GCC 编译成 EXE,EXE 内执行初始化、物性、流量、机械、RK45/CVODE BDF、事件和采样。求解循环不调用 Python。 -目标不是逐行翻译源模型,而是建立可运行、可测试、可导出,并能与 OpenModelica 或 AMESim baseline 对比的 Python 仿真框架。 +## 目录 -当前包含两条模型线:`Testmodel` 已有可运行的 ODE 近似和 OpenModelica 对比能力;`test_mql` 已形成 132 状态气动机械总闭包,正在按 AMESim baseline 做数值校准。 +- `components/`:模型参数、端口、显示和结果声明。 +- `core/`、`registry.py`、`systems/network.py`:模型合同与网络结构校验。 +- `native_codegen/`:C 生成、构建、进程运行和 CLI。 +- `config.py`、`sampling.py`、`results.py`:公共配置、进度、采样网格校验与结果类型。 +- `performance.py`、`warmup.py`:编排计时和启动工具链检查。 +- `reporting/amesim_results.py`:外部 Amesim 结果读取。 +- 仓库根目录 `native/`:C 组件公式和求解器。 -## 当前目录 +旧 Python 积分器、数值组件方法和固定算例专用求解器已经退役。旧 `/api/reactflow/simulate-testmodel`、`/api/reactflow/simulate-test-mql` 返回 410;使用 `/api/system-xml/simulate` 或流式接口。 -- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。 -- `solvers/`: ODE、压力流量代数方程和 stream 求解。 -- `components/experimental/`: 用于验证元件开发规范的临时组件库。 -- `components/experimental/storage/`: 气瓶和贮箱等储能元件。 -- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。 -- `components/experimental/junctions/`: 三通等连接节点。 -- `components/amesim/`: AMESim 气动、信号和机械组件原语。 -- `systems/`: 通用仿真网络与 XML 驱动系统装配。 -- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和基线运行入口。 -- `examples/test_mql/`: AMESim `test_mql` 的系统装配、校准原语、诊断和运行入口。 -- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。 -- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。 -- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。 - -稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的 -`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。 -需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。 - -FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow -启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。 - -临时组件库的声明入口是 `components/experimental/library.py`,AMESim 第一版 -公开临时库入口是 `components/amesim/library.py`。公开模型必须在 -模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS / -RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见 -[`组件模型建模规范 v1`](../../docs/standard/component-model-authoring-spec-v1.md)和 -[`组件库分类、发现与读取规范 v1`](../../docs/standard/component-library-spec-v1.md)。 - -当前关键文件: - -- `core/medium.py`: 气体介质协议 `GasMedium` 与通用理想气体实现 `IdealGasMedium` -- `components/amesim/media/`: AMESim 零端口介质物性定义元件;具体类型确定介质,`property_model` 下拉参数选择计算方法,当前提供空气理想气体和氦气 Peng-Robinson -- `components/amesim/gases.py`: AMESim `gi` 介质物性实例注册表;`gi=0` 固定为空气(理想气体,内置默认),`gi=1..99` 引用画布中的显式介质定义 -- `core/peng_robinson.py`: `test_mql` 与公开氦气介质共用的 Peng-Robinson 状态方程 -- `performance.py`: 默认关闭、按单次仿真隔离的阶段与物性性能埋点 -- `benchmark_performance.py`: System XML 主求解路径的可重复命令行基准工具 -- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装 -- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回 -- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔 -- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper -- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口 -- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写 -- `examples/test_mql/system.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射 -- `examples/test_mql/closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回 -- `examples/test_mql/primitives/`: 固定算例专用的 Peng-Robinson 氦气、管路和机械校准原语 -- `examples/test_mql/structural_network.py`: 固定算例专用的结构网络;不替代带端口契约校验的通用网络 -- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出 -- `reporting/amesim_results.py`: AMESim 结果读取入口 -- `examples/test_mql/run_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口 -- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口 -- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试 - -## 可选性能诊断 - -`SIMULATIONAPP_PROFILE` 支持 `off`(默认)、`standard` 和 `audit`。`standard` -只统计低频的大阶段;`audit` 才展开 RHS、代数闭合、stream 和物性调用,开销也 -明显更高。最终优化收益必须在 `off` 下复测。 - -Peng–Robinson 氦气的高开销物性默认使用一次仿真内独立的精确 LRU 缓存;不同 -仿真任务不会共享条目,仿真结束后自动释放。可在启动进程前设置 -`SIMULATIONAPP_PROPERTY_CACHE=off` 做数值和性能 A/B,正常运行保持默认 `on`。 -缓存只复用完全相同的输入,不做四舍五入或容差匹配。 - -FastAPI worker 默认在 lifespan 启动阶段预热 SciPy 积分、非线性求解、稀疏 -Jacobian 和 System XML XSD,完成后才开始接收请求。它不会运行业务模型,也不 -写入文件;如需诊断冷启动,可设置 `SIMULATIONAPP_WARMUP=off`。每个 worker 都会 -独立暖机一次。 - -```powershell -.venv-win\Scripts\python.exe -m app.simulation.benchmark_performance ` - --mode audit --warmups 1 --runs 3 ` - --factory "helium_step=tests.test_amesim_pnvo001_signal_xml:high_pressure_helium_step_project" ` - --output app/data/performance-evaluations/helium-step.json -``` - -缓存关闭对照可在同一命令中增加 `--disable-property-cache`。缓存容量、命中、 -未命中和驱逐数会在 audit 响应的 -`diagnostics.performance.propertyCache` 中返回。 - -基准原始 JSON 默认放到已忽略的 `app/data/` 下。指标字段、实测结果和使用边界见 -[`仿真性能评估 2026-08-15`](../../docs/other/仿真性能评估-2026-08-15.md)。 - -## 当前阶段进度 - -这一阶段原先有 4 件重点工作,现在的状态如下: - -1. `mytee1` 的 stream/焓传播语义:已完成当前阶段收紧 - 现在如果只有一条支路发生倒流,下游来流焓统一按 `tank.h` 处理,不再临时借另一条支路的焓来凑。 -2. 下游初始化/约束处理:已完成当前阶段收口 - 之前是“直接改对象状态再开始积分”,现在已经收成显式的 `consistent_initial_state_vector()` 初始化入口。当前这一步会在不改下游总质量、总内能的前提下,把几段直接相连的体积拉回同一个连接压力。 -3. 自动校验:已完成当前阶段首版 - 已经补了标准库 `unittest` 回归测试,先把初始化投影是否守恒、是否污染原始状态,以及 4 个主变量的提交基线锁住。 -4. 更严格介质模型:已完成当前阶段首版 - 已经从固定 `cp/cv` 的理想气体近似,推进到随温度变化的空气近似,并接上了内能反解和初始化求根。 - -如果只看结果,可以把这一阶段理解成: - -- 连接器语义:首轮收紧已完成 -- 初始化入口:首轮收口已完成 -- 基线验证:首轮保护已完成 -- 介质精化:首轮近似已完成 - -## 当前阶段收口 - -上一轮 `N0-N3` 已全部完成首版,当前可以简单理解为: - -1. `N0`:系统层里最明显的流向/焓判断已经继续下沉到组件 helper。 -2. `N1`:模型参数和运行参数已经收口到配置对象。 -3. `N2`:运行接口已经分成“准备请求”和“执行请求”两层。 -4. `N3`:结果导出和命令行报告格式化已经统一收口到 `reporting/`。 - -这一轮结束后,项目已经不缺“能不能跑”的能力,下一步更重要的是把后续开发最容易卡住的地方先处理掉。 - -## 本次推送更新 - -本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态: - -1. `M2`:已完成当前阶段首版 - - 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py` - - `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节 - -2. `M3`:已完成当前阶段首版 - - 已给两条支路入口流量固定点求解、下游公共压力投影补了显式诊断 - - 诊断内容至少包含 `converged / iterations / residual` - - 已支持严格模式;内部求解不收敛时可以直接抛错,而不是静默返回最后一个近似值 - - `run_testmodel()` 的结构化结果和 `testmodel_run_report.txt` 已能带出最后一次内部闭合求解诊断 - -3. `M4`:已完成当前阶段首版 - - 自动测试已不再只盯最终主变量结果 - - 现在已经覆盖: - - 改支路参数后,初始支路入口流量是否按预期变化 - - 更偏激配置下,初始化和内部闭合是否仍然收敛 - - 有无 Modelica 参考两种运行路径下,程序接口与产物行为是否一致 - -4. `M5`:已启动 - - 当前已经明确选择优先走“更容易扩展”的方向,而不是先追求更贴近 Modelica - - 已完成第一步:把闭合器内部原来大量写死的 `upper/lower` 双支路逻辑,收成可复用的 `BranchClosureComponents / BranchClosureState` 结构 - - 当前已继续推进到 `G1-G5` 的首轮兼容层改造:`snapshot` 已提供通用分支集合,系统层结果生成已拆成“通用键生成 + 旧键别名派生”两层,报告层已开始优先消费通用分支键,旧导出列名仍通过兼容映射保留,兼容测试已显式保护分支顺序和旧导出语义 - -## 下一阶段接手建议 - -如果继续往前推进,建议按下面顺序做,而不是再零散补功能: - -1. `G1`:已完成当前阶段首轮兼容接入 - - `TestModelSnapshot` 已新增 `branches` 集合 - - 每个分支当前至少带 `name / pipe / inlet_flow / outlet_flow / inlet_h / inlet_flow_diagnostics` - - `pipe_upper / pipe_lower / branch_inlet_flows / branch_outlet_flows` 目前仍保留为兼容属性,供旧调用方继续使用 - -2. `G2`:已完成当前阶段首轮内部迁移 - - `evaluate_solution()` 已改成从 `snapshot.branches` 读取数据,再通过显式分支名映射写回当前旧列名 - - `rhs()` 里的分支导数计算已改成通过通用 helper 按分支循环生成,再按当前状态向量顺序拼回 - - 当前外部导出列名仍保持兼容: - - `mypipe.p` - - `mypipe1.p` - - `branch_upper.in/out` - - `branch_lower.in/out` - -3. `G3`:已完成当前阶段首轮兼容测试 - - 当前测试已经显式保护: - - `branches` 顺序是否稳定 - - `snapshot` 新字段和兼容字段是否一致 - - 旧导出列名是否仍映射到正确分支语义 - - 参数变化后 `upper/lower` 的名字和顺序是否不会被打乱 - -4. `G4`:已完成当前阶段首轮兼容拆层 - - `evaluate_solution()` 现在会同时产出: - - 通用分支键:`branch..p/in/out` - - 旧兼容键:`mypipe.p`、`mypipe1.p`、`branch_upper.*`、`branch_lower.*` - - 报告层当前已开始优先读取通用分支键,旧键只作为兼容后备 - - 当前已经把“内部统一表达”和“旧接口兼容导出”拆成两层,但还没有把所有报告/导出逻辑都迁干净 - -5. `G5`:已完成当前阶段首轮兼容收口 - - `evaluate_solution()` 当前会先生成通用分支键,再统一派生旧兼容键 - - 报告层当前已支持“通用键优先、旧键兼容后备” - - 当前已经把系统层和 reporting 层的主要旧专名读取入口收口到少量 helper 上,后续继续迁移不会再到处散改 - -6. `P1`:下一阶段建议从这里接手 - 当前更合适的下一步,不是继续深挖内核通用化,而是切回结果导向主线: - - 定义一份稳定的外部输入参数 schema - - 明确这些结构化参数如何映射到 `TestModelConfig / TestModelRunConfig` - - 建立“结构化参数 -> 仿真执行 -> 结果产物/摘要”的稳定接口 - 这样可以直接服务后续文档解析、网页入口和报告生成,而不是继续在 `Testmodel` 内部做边际收益越来越低的抽象整理 - -7. `P2`:在 `P1` 完成后,再推进文档解析或报告生成链路 - 更现实的顺序应是: - - 先把结构化输入跑通 - - 再把结果摘要/产物组织成更接近最终产品的输出包 - - 最后再接 Word 解析或页面入口 - -如果后续继续推进,这个 README 也要一起更新,不要长期保留已经失效的路线描述。 - -## 当前实现了什么 - -当前代码已经实现: - -1. `m`、`U` 作为动态元件主状态,`p`、`T`、`rho`、`u`、`h` 作为派生量。 -2. `Cylinder`、`Tank`、`Pipe` 的刚性绝热容腔近似。 -3. `Orifice` 的压差开方流量关系。 -4. `Tee` 的简化混合焓处理。 -5. `Testmodel` 的系统级拓扑映射和一版可运行的 `rhs(t, x)`。 -6. 基于 `solve_ivp` 的积分入口,以及 SciPy 不可用时的 RK4 回退。 -7. 温度相关空气近似介质,包括 `cp(T)`、`h(T)`、`u(T)` 以及 `u -> T` 反解。 -8. 显式一致初值入口 `consistent_initial_state_vector()`,以及可迭代初始化器 `initialize_consistent_state()`。 -9. Python 主变量结果导出: - `mytank.p`、`mytank.T`、`mycylinder.p`、`mycylinder.T` -10. 贮箱温度曲线导出: - `testmodel_tank_temperature.csv` - `testmodel_tank_temperature.svg` -11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。 -12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。 -13. 面向 System XML v3 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。 - -当前没有实现: - -- 通用 DAE 初始化器 -- `Modelica.Media.Air.SimpleAir` 的严格复刻 -- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器 - -## 当前怎么运行 - -最小运行方式: - -```bash -python -m app.simulation.examples.testmodel.run -``` - -如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如: - -```python -from app.simulation.examples.testmodel.run import ( - TestModelRunConfig, - TestModelSamplingConfig, - run_testmodel, -) -from app.simulation.examples.testmodel.system import ( - BranchConfig, - CylinderConfig, - OrificeConfig, - PipeConfig, - TankConfig, - TestModelConfig, -) -from app.simulation.solvers.solver import SolveIVPConfig - -run_config = TestModelRunConfig( - model=TestModelConfig( - cylinder=CylinderConfig(p0=30e6), - upper_branch=BranchConfig( - orifice=OrificeConfig(K=8e-6), - pipe=PipeConfig(length=6.0, diameter=0.03), - ), - tank=TankConfig(volume=0.12), - ), - solver=SolveIVPConfig(t_start=0.0, t_stop=10.0, method="BDF"), - sampling=TestModelSamplingConfig(step=0.05), -) - -result = run_testmodel(run_config=run_config) -``` - -如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行: - -```python -from app.simulation.examples.testmodel.run import ( - prepare_testmodel_run, - run_prepared_testmodel, - TestModelRunConfig, -) - -prepared = prepare_testmodel_run(run_config=TestModelRunConfig()) -print(prepared.output_dir) -print(prepared.t_eval) - -result = run_prepared_testmodel(prepared) -print(result.artifacts.primary_csv_path) -print(result.used_modelica_reference) -``` - -当前脚本会: - -1. 构建 `TestModelSystem` -2. 打印原始初值向量与约束一致后的初值向量 -3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s` -4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录 -5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果 - -当前脚本默认不会把运行结果直接写到提交基线目录,而是会在 -`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如: - -- `app/data/simulation-runs/testmodel_20260512_103000_123456/` - -该目录里通常会包含: - -- `testmodel_primary_series.csv` -- `testmodel_tank_temperature.csv` -- `testmodel_tank_temperature.svg` -- `testmodel_run_report.txt` -- `testmodel_modelica_comparison.csv` -- `testmodel_modelica_comparison_summary.txt` - -## 基线结果 - -当前基线对比摘要来自: -[`testmodel_modelica_comparison_summary.txt`](../../tests/data/testmodel/testmodel_modelica_comparison_summary.txt) - -当前四个主变量的最大误差为: - -- `mytank.p`: `max_abs_error = 134.960858 Pa`, `max_rel_error = 0.006798%` -- `mytank.T`: `max_abs_error = 0.035507 K`, `max_rel_error = 0.009016%` -- `mycylinder.p`: `max_abs_error = 1391.986349 Pa`, `max_rel_error = 0.009447%` -- `mycylinder.T`: `max_abs_error = 0.009069 K`, `max_rel_error = 0.003870%` - -这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。 - -## AMESim test_mql 当前进度 - -`test_mql` 从 `AmesimModels/test_mql.ame` 迁移,并与旧 `testmodel` 保持独立。 -固定算例实现统一位于 `examples/test_mql/`,结果读取和比较能力位于 -`reporting/`;公开拖拽组件由 `components/amesim/library.py` 单独登记。 - -当前已形成 112 个气动状态和 20 个机械状态的总闭包、AMESim 原生结果读取、 -`Data_Path` 输出校验及短时域 comparison。这里不再复制易过期的数值进度; -最新对比结果、运行命令、限制和下一步校准路径以 -[`AmesimModels/test_mql/README.md`](../../AmesimModels/test_mql/README.md) -为唯一说明。当前仍不能宣称 Python 时域仿真与 AMESim 全局一致。 - -## Testmodel 当前架构判断 - -如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于: - -- 组件级:已完成首版 -- 系统闭合:已完成首版 -- 积分入口:已完成首版 -- 基线结果对齐:已具备初步能力 -- 去近似:仍在进行中 - -所以当前最准确的说法不是“已完成移植”,而是: - -`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。 - -## Testmodel 已知限制 - -当前最主要的限制可以直接理解成下面几条: - -- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。 -- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。 -- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。 -- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。 -- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。 -- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。 -- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。 -- 当前内核已经足够支撑下一阶段“结构化参数 -> 仿真执行 -> 产物输出”的链路开发,但还没有现成的 Word 参数解析入口和正式报告生成链路。 - -所以,当前版本适合: - -- 架构验证 -- 组件接口验证 -- 基线工况对比 -- 结果导出与误差定位 - -但当前版本还不适合: - -- 直接宣称与 OpenModelica 严格等价 -- 作为最终工程结论的唯一依据 -- 直接扩展到更复杂拓扑而不补通用连接器语义 - -## Testmodel 文件级现状 - -按代码现状逐项看: - -- `core/base.py`: 正常 - 只提供最小抽象层,没有明显冗余。 -- `core/ports.py`: 正常 - `PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。 -- `core/state.py`: 正常 - `VolumeState` 只负责 `[m, U]` 状态打包。 -- `systems/network.py`: 正常 - 负责状态向量拼装和连接摘要,不参与物理求解。 -- `solvers/solver.py`: 正常 - 已支持 SciPy、RK4 回退和零时长仿真。 -- `components/experimental/**/*.py`: 正常 - 都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。 -- `examples/testmodel/system.py`: 是当前最重要的技术债集中区 - 这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。 -- `examples/testmodel/run.py`: 正常 - 已不是“最小打印脚本”,而是当前结果导出和对比入口。 -- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。 -- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。 - -## Testmodel 当前主技术债 - -目前最主要的技术债,可以直接理解成下面 4 件事: - -1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。 -2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。 -3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。 -4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。 +运行、支持范围与依赖见 [C 后端说明](../../native/README.md)。模型开发规则见 [组件规范](../../docs/standard/component-model-authoring-spec-v1.md)。历史性能、Amesim 差异和旧公式说明位于 `docs/other/` 及 Git 历史,不能作为当前运行入口。 diff --git a/app/simulation/backends.py b/app/simulation/backends.py new file mode 100644 index 0000000..fe7f09e --- /dev/null +++ b/app/simulation/backends.py @@ -0,0 +1,37 @@ +"""One execution boundary shared by XML API and native validation tools.""" +from __future__ import annotations + +import os + +from app.simulation.config import SolveIVPConfig + + +DEFAULT_NUMERIC_ENGINE = "native" + + +def numeric_engine_name(backend: str | None = None) -> str: + configured = backend or os.environ.get("SIMULATION_NUMERIC_ENGINE", "") + selected = configured.strip().lower() or DEFAULT_NUMERIC_ENGINE + if selected == "native-c": + return "native" + if selected == "native": + return selected + if selected == "python": + raise ValueError("The Python numerical backend has been removed. Use SIMULATION_NUMERIC_ENGINE=native.") + raise ValueError(f"Unknown simulation engine: {selected}.") + + +def simulation_config(simulation) -> SolveIVPConfig: + # Preserve the existing XML execution accuracy. The XML + # protocol's future tolerance fields are a separate compatibility change. + return SolveIVPConfig(t_start=simulation.t_start, t_stop=simulation.t_stop, + method=simulation.method, rtol=1e-6, max_step=simulation.max_step) + + +def simulate_network(network, simulation, *, progress_callback=None, + cancel_check=None, activity_tracker=None, backend=None): + numeric_engine_name(backend) + config = simulation_config(simulation) + from app.simulation.native_codegen.runner import simulate_native + return simulate_native(network, config, sample_step=simulation.sample_step, progress_callback=progress_callback, + cancel_check=cancel_check, activity_tracker=activity_tracker) diff --git a/app/simulation/benchmark_performance.py b/app/simulation/benchmark_performance.py deleted file mode 100644 index e249fb6..0000000 --- a/app/simulation/benchmark_performance.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -import argparse -import hashlib -import importlib -import json -import os -import platform -import statistics -import sys -from datetime import UTC, datetime -from math import ceil -from pathlib import Path -from time import perf_counter_ns, process_time_ns -from typing import Any - - -def _named_value(value: str, *, option: str) -> tuple[str, str]: - name, separator, target = value.partition("=") - if not separator or not name.strip() or not target.strip(): - raise ValueError( - f"{option} must use NAME=VALUE syntax, received {value!r}." - ) - return name.strip(), target.strip() - - -def _percentile(values: list[float], percentile: float) -> float: - ordered = sorted(values) - index = max(0, min(len(ordered) - 1, ceil(percentile * len(ordered)) - 1)) - return ordered[index] - - -def _duration_summary(values: list[float]) -> dict[str, object]: - return { - "samplesMs": values, - "minimumMs": min(values), - "medianMs": statistics.median(values), - "p95Ms": _percentile(values, 0.95), - "maximumMs": max(values), - } - - -def _load_factory_xml(specification: str) -> bytes: - module_name, separator, member_name = specification.partition(":") - if not separator or not module_name or not member_name: - raise ValueError( - "Factory specifications must use module.path:callable syntax." - ) - factory = getattr(importlib.import_module(module_name), member_name) - value = factory() - if isinstance(value, bytes): - return value - if isinstance(value, str): - return value.encode("utf-8") - - from app.main import build_reactflow_system_xml - - return build_reactflow_system_xml(value) - - -def _serialize_result_event(result: dict[str, object]) -> bytes: - """Render the final NDJSON payload shape used by the streaming endpoint.""" - - status = str(result.get("status", "completed")) - event = { - "event": "result", - "progress": 100 if status == "completed" else 0, - "phase": status, - "message": "仿真完成" if status == "completed" else "仿真任务结束", - "simulatedTime": result.get("simulatedUntil"), - "totalTime": result.get("requestedStopTime"), - "result": result, - } - return ( - json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n" - ).encode("utf-8") - - -def _run_case( - name: str, - xml_bytes: bytes, - *, - warmups: int, - runs: int, - cancellable_path: bool, - allow_failures: bool, -) -> dict[str, object]: - from app.main import run_system_xml_simulation - - cancel_check = (lambda: False) if cancellable_path else None - for _ in range(warmups): - result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check) - if not bool(result.get("success")) and not allow_failures: - raise RuntimeError(f"Warmup for {name!r} failed: {result.get('message')}") - - wall_samples_ms: list[float] = [] - cpu_samples_ms: list[float] = [] - serialization_samples_ms: list[float] = [] - serialized_sizes: list[int] = [] - profiles: list[dict[str, object]] = [] - final_result: dict[str, object] | None = None - for _ in range(runs): - wall_start = perf_counter_ns() - cpu_start = process_time_ns() - result = run_system_xml_simulation(xml_bytes, cancel_check=cancel_check) - cpu_samples_ms.append((process_time_ns() - cpu_start) / 1_000_000.0) - wall_samples_ms.append((perf_counter_ns() - wall_start) / 1_000_000.0) - if not bool(result.get("success")) and not allow_failures: - raise RuntimeError(f"Benchmark for {name!r} failed: {result.get('message')}") - diagnostics = result.get("diagnostics") - if isinstance(diagnostics, dict): - performance = diagnostics.get("performance") - if isinstance(performance, dict): - profiles.append(performance) - serialization_start = perf_counter_ns() - serialized_event = _serialize_result_event(result) - serialization_samples_ms.append( - (perf_counter_ns() - serialization_start) / 1_000_000.0 - ) - serialized_sizes.append(len(serialized_event)) - final_result = result - - assert final_result is not None - return { - "name": name, - "success": bool(final_result.get("success")), - "message": final_result.get("message"), - "inputBytes": len(xml_bytes), - "inputSha256": hashlib.sha256(xml_bytes).hexdigest(), - "status": final_result.get("status"), - "simulatedUntil": final_result.get("simulatedUntil"), - "requestedStopTime": final_result.get("requestedStopTime"), - "wall": _duration_summary(wall_samples_ms), - "cpu": _duration_summary(cpu_samples_ms), - "resultSerialization": _duration_summary(serialization_samples_ms), - "resultEventBytes": serialized_sizes, - "performanceRuns": profiles, - } - - -def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Benchmark the real System XML simulation path with optional profiling." - ) - parser.add_argument( - "--mode", - choices=("off", "standard", "audit"), - default="audit", - help="Instrumentation depth selected before importing the simulation modules.", - ) - parser.add_argument("--warmups", type=int, default=1) - parser.add_argument("--runs", type=int, default=5) - parser.add_argument( - "--xml", - action="append", - default=[], - metavar="NAME=PATH", - help="Add an XML file benchmark case.", - ) - parser.add_argument( - "--factory", - action="append", - default=[], - metavar="NAME=MODULE:CALLABLE", - help="Add a zero-argument factory returning XML or ReactFlowProjectPayload.", - ) - parser.add_argument( - "--direct-path", - action="store_true", - help="Do not pass a cancel callback; use the one-shot SciPy path when eligible.", - ) - parser.add_argument( - "--disable-property-cache", - action="store_true", - help="Disable the run-local exact property cache for an A/B comparison.", - ) - parser.add_argument( - "--allow-failures", - action="store_true", - help="Record failed simulation runs instead of aborting the benchmark.", - ) - parser.add_argument("--output", type=Path) - arguments = parser.parse_args(argv) - if arguments.warmups < 0: - parser.error("--warmups must not be negative.") - if arguments.runs <= 0: - parser.error("--runs must be positive.") - if not arguments.xml and not arguments.factory: - parser.error("At least one --xml or --factory case is required.") - return arguments - - -def main(argv: list[str] | None = None) -> int: - arguments = _parse_arguments(argv) - os.environ["SIMULATIONAPP_PROFILE"] = arguments.mode - os.environ["SIMULATIONAPP_PROPERTY_CACHE"] = ( - "off" if arguments.disable_property_cache else "on" - ) - - cases: list[tuple[str, bytes]] = [] - for raw_case in arguments.xml: - name, raw_path = _named_value(raw_case, option="--xml") - cases.append((name, Path(raw_path).read_bytes())) - for raw_case in arguments.factory: - name, specification = _named_value(raw_case, option="--factory") - cases.append((name, _load_factory_xml(specification))) - - report: dict[str, Any] = { - "generatedAt": datetime.now(UTC).isoformat(), - "profileMode": arguments.mode, - "cancellableSolverPath": not arguments.direct_path, - "propertyCacheEnabled": not arguments.disable_property_cache, - "allowFailures": bool(arguments.allow_failures), - "warmups": arguments.warmups, - "runs": arguments.runs, - "runtime": { - "python": sys.version, - "platform": platform.platform(), - "processor": platform.processor(), - }, - "cases": [ - _run_case( - name, - xml_bytes, - warmups=arguments.warmups, - runs=arguments.runs, - cancellable_path=not arguments.direct_path, - allow_failures=arguments.allow_failures, - ) - for name, xml_bytes in cases - ], - } - text = json.dumps(report, ensure_ascii=False, indent=2) - if arguments.output is not None: - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text(text + "\n", encoding="utf-8") - print(f"Performance report written to {arguments.output.resolve()}") - else: - print(text) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/app/simulation/benchmark_regression.py b/app/simulation/benchmark_regression.py deleted file mode 100644 index 2e5bb04..0000000 --- a/app/simulation/benchmark_regression.py +++ /dev/null @@ -1,2468 +0,0 @@ -"""Manifest-driven, progressively bounded System XML regression runner. - -The parent process launches one child process per requested simulation horizon. -It first asks the child to cancel cooperatively at the soft deadline and then -terminates it at the hard deadline. This keeps an unexpectedly expensive -long-run probe from blocking later optimization work indefinitely. - -The source XML is immutable and authoritative. Stop time, sampling, and an -explicitly configured lane max step are changed only in the child process's -in-memory XML copy. -""" - -from __future__ import annotations - -import argparse -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from datetime import UTC, datetime -import hashlib -import importlib.metadata -import json -import math -import os -from pathlib import Path -import platform -import queue -import subprocess -import sys -import threading -from time import monotonic, perf_counter, process_time -from typing import Any -import xml.etree.ElementTree as ET - -from app.simulation.physical_state_v21 import ( - PhysicalStateV21Error, - evaluate_physical_state_v21, - load_approved_golden as load_physical_state_v21_golden, - physical_state_v21_applicable, - project_physical_state_v21, -) - -try: # resource is unavailable on native Windows Python. - import resource -except ImportError: # pragma: no cover - Windows regression job - resource = None # type: ignore[assignment] - - -REPORT_SCHEMA_VERSION = 2 -MANIFEST_SCHEMA_VERSION = 1 -REGRESSION_GOLDEN_SCHEMA_VERSION = 1 -DEFAULT_MANIFEST_PATH = ( - Path(__file__).resolve().parents[2] - / "tests" - / "baselines" - / "simulation" - / "test_mql_8" - / "manifest.json" -) - - -class RegressionManifestError(ValueError): - """Raised when a regression manifest is incomplete or inconsistent.""" - - -@dataclass(frozen=True) -class RegressionCaseRequest: - case_id: str - source_path: Path - expected_sha256: str - lane: str - stop_time: float - sample_step: float - max_step: float - checkpoint_times: tuple[float, ...] - soft_timeout_seconds: float - hard_timeout_seconds: float - termination_grace_seconds: float - instrumentation_mode: str = "standard" - environment_overrides: tuple[tuple[str, str], ...] = () - - -CaseExecutor = Callable[[RegressionCaseRequest], dict[str, object]] - - -def _sha256(payload: bytes) -> str: - return hashlib.sha256(payload).hexdigest() - - -def _canonical_json_sha256(value: object) -> str: - payload = json.dumps( - value, - default=str, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - return _sha256(payload) - - -def _valid_sha256(value: object) -> bool: - return ( - isinstance(value, str) - and len(value) == 64 - and all(character in "0123456789abcdef" for character in value) - ) - - -def _repository_root(manifest_path: Path) -> Path: - for candidate in (manifest_path.parent, *manifest_path.parents): - if (candidate / "app").is_dir() and (candidate / "tests").is_dir(): - return candidate - raise RegressionManifestError( - f"Could not locate the repository root above {manifest_path}." - ) - - -def _finite_positive(value: object, *, field: str) -> float: - try: - numeric = float(value) - except (TypeError, ValueError) as exc: - raise RegressionManifestError(f"{field} must be numeric.") from exc - if not math.isfinite(numeric) or numeric <= 0.0: - raise RegressionManifestError(f"{field} must be finite and positive.") - return numeric - - -def _finite_nonnegative(value: object, *, field: str) -> float: - try: - numeric = float(value) - except (TypeError, ValueError) as exc: - raise RegressionManifestError(f"{field} must be numeric.") from exc - if not math.isfinite(numeric) or numeric < 0.0: - raise RegressionManifestError( - f"{field} must be finite and non-negative." - ) - return numeric - - -def load_regression_golden( - path: Path | str, - *, - expected_sha256: str | None = None, - repository_root: Path | None = None, -) -> dict[str, object]: - """Load one reviewed physical/output contract without trusting its values.""" - - golden_path = Path(path).resolve() - try: - payload = golden_path.read_bytes() - golden = json.loads(payload) - except (OSError, json.JSONDecodeError) as exc: - raise RegressionManifestError( - f"Could not read regression golden {golden_path}: {exc}" - ) from exc - actual_sha256 = _sha256(payload) - if expected_sha256 is not None and actual_sha256 != expected_sha256: - raise RegressionManifestError( - "Regression golden hash mismatch: " - f"expected {expected_sha256}, received {actual_sha256}." - ) - if not isinstance(golden, dict): - raise RegressionManifestError("Regression golden must be a JSON object.") - if golden.get("schemaVersion") != REGRESSION_GOLDEN_SCHEMA_VERSION: - raise RegressionManifestError( - "Unsupported regression golden schemaVersion: " - f"{golden.get('schemaVersion')!r}." - ) - for field in ("id", "caseId", "lane", "sourceXmlSha256"): - if not isinstance(golden.get(field), str) or not golden[field]: - raise RegressionManifestError( - f"Regression golden {field} must be a non-empty string." - ) - if not _valid_sha256(golden["sourceXmlSha256"]): - raise RegressionManifestError( - "Regression golden sourceXmlSha256 must be a lowercase SHA-256 digest." - ) - - approval = golden.get("approval") - if not isinstance(approval, Mapping) or approval.get("status") != "approved": - raise RegressionManifestError( - "Regression golden must have approval.status='approved'." - ) - provenance = golden.get("provenance") - source_report = ( - provenance.get("sourceReport") - if isinstance(provenance, Mapping) - else None - ) - if not isinstance(source_report, Mapping): - raise RegressionManifestError( - "Regression golden provenance.sourceReport must be an object." - ) - report_path_value = source_report.get("path") - report_sha256 = source_report.get("sha256") - report_generated_at = source_report.get("generatedAt") - if not isinstance(report_path_value, str) or not report_path_value: - raise RegressionManifestError( - "Regression golden source report path must be non-empty." - ) - if not _valid_sha256(report_sha256): - raise RegressionManifestError( - "Regression golden source report sha256 must be a lowercase digest." - ) - if not isinstance(report_generated_at, str) or not report_generated_at: - raise RegressionManifestError( - "Regression golden source report generatedAt must be non-empty." - ) - compatibility = source_report.get("metadataCompatibility") - if not isinstance(compatibility, Mapping) or compatibility.get("status") not in { - "current", - "acceptedHistorical", - }: - raise RegressionManifestError( - "Regression golden source report must declare metadata compatibility." - ) - differences = compatibility.get("differences") - if not isinstance(differences, list) or not all( - isinstance(value, str) and value for value in differences - ): - raise RegressionManifestError( - "Regression golden metadata compatibility differences must be strings." - ) - if compatibility.get("status") == "current" and differences: - raise RegressionManifestError( - "A current source report must not declare metadata differences." - ) - - layout = golden.get("physicalLayout") - if not isinstance(layout, Mapping): - raise RegressionManifestError( - "Regression golden physicalLayout must be an object." - ) - categories = layout.get("projectionCategories") - state_keys = layout.get("stateKeys") - if not isinstance(categories, list) or "state" not in categories or not all( - isinstance(value, str) and value for value in categories - ): - raise RegressionManifestError( - "Regression golden projectionCategories must contain 'state'." - ) - if not isinstance(state_keys, list) or not state_keys or not all( - isinstance(value, str) and value for value in state_keys - ): - raise RegressionManifestError( - "Regression golden stateKeys must be a non-empty string list." - ) - if len(set(state_keys)) != len(state_keys): - raise RegressionManifestError("Regression golden stateKeys must be unique.") - if layout.get("stateKeyLayoutSha256") != _canonical_json_sha256(state_keys): - raise RegressionManifestError( - "Regression golden stateKeyLayoutSha256 does not match stateKeys." - ) - - tolerance = golden.get("tolerance") - if not isinstance(tolerance, Mapping): - raise RegressionManifestError( - "Regression golden tolerance must be an object." - ) - _finite_nonnegative( - tolerance.get("relative"), field="golden.tolerance.relative" - ) - _finite_nonnegative( - tolerance.get("absolute"), field="golden.tolerance.absolute" - ) - _finite_nonnegative( - tolerance.get("checkpointTimeAbsoluteSeconds"), - field="golden.tolerance.checkpointTimeAbsoluteSeconds", - ) - - checkpoints = golden.get("physicalCheckpoints") - if not isinstance(checkpoints, list) or not checkpoints: - raise RegressionManifestError( - "Regression golden physicalCheckpoints must be a non-empty list." - ) - for index, checkpoint in enumerate(checkpoints): - if not isinstance(checkpoint, Mapping): - raise RegressionManifestError( - f"Regression golden checkpoint {index} must be an object." - ) - _finite_nonnegative( - checkpoint.get("requestedTime"), - field=f"golden.physicalCheckpoints[{index}].requestedTime", - ) - values = checkpoint.get("values") - if not isinstance(values, list) or len(values) != len(state_keys): - raise RegressionManifestError( - f"Regression golden checkpoint {index} has the wrong value layout." - ) - if any( - not isinstance(value, (int, float)) or not math.isfinite(float(value)) - for value in values - ): - raise RegressionManifestError( - f"Regression golden checkpoint {index} values must be finite numbers." - ) - - output_contract = golden.get("outputContract") - if output_contract is not None: - if not isinstance(output_contract, Mapping) or not _valid_sha256( - output_contract.get("sha256") - ): - raise RegressionManifestError( - "Regression golden outputContract.sha256 must be a lowercase digest." - ) - - if repository_root is not None: - root = repository_root.resolve() - report_path = (root / report_path_value).resolve() - if not report_path.is_relative_to(root): - raise RegressionManifestError( - "Regression golden source report must remain inside the repository." - ) - try: - report_payload = report_path.read_bytes() - except OSError as exc: - raise RegressionManifestError( - f"Could not read golden source report {report_path}: {exc}" - ) from exc - if _sha256(report_payload) != report_sha256: - raise RegressionManifestError( - "Regression golden source report hash no longer matches provenance." - ) - try: - source_report_document = json.loads(report_payload) - except json.JSONDecodeError as exc: - raise RegressionManifestError( - "Regression golden source report is not valid JSON." - ) from exc - report_source = ( - source_report_document.get("source") - if isinstance(source_report_document, Mapping) - else None - ) - if ( - not isinstance(report_source, Mapping) - or report_source.get("sha256") != golden["sourceXmlSha256"] - or source_report_document.get("generatedAt") != report_generated_at - ): - raise RegressionManifestError( - "Regression golden source report identity does not match the golden." - ) - report_cases = source_report_document.get("cases") - matching_cases = ( - [ - case - for case in report_cases - if isinstance(case, Mapping) - and case.get("caseId") == golden["caseId"] - and case.get("lane") == golden["lane"] - ] - if isinstance(report_cases, list) - else [] - ) - if len(matching_cases) != 1: - raise RegressionManifestError( - "Regression golden source report does not contain exactly one matching case." - ) - worker = matching_cases[0].get("worker") - summary = worker.get("summary") if isinstance(worker, Mapping) else None - report_checkpoints = ( - summary.get("physicalContract", {}).get("checkpoints") - if isinstance(summary, Mapping) - and isinstance(summary.get("physicalContract"), Mapping) - else summary.get("checkpoints") if isinstance(summary, Mapping) else None - ) - if not isinstance(report_checkpoints, list) or len(report_checkpoints) != len( - checkpoints - ): - raise RegressionManifestError( - "Regression golden checkpoints do not match the source report." - ) - for golden_checkpoint, report_checkpoint in zip( - checkpoints, report_checkpoints - ): - report_values = ( - report_checkpoint.get("stateValues") - if isinstance(report_checkpoint, Mapping) - else None - ) - extracted_values = ( - [report_values.get(key) for key in state_keys] - if isinstance(report_values, Mapping) - and set(report_values) == set(state_keys) - else None - ) - if ( - not isinstance(report_checkpoint, Mapping) - or report_checkpoint.get("requestedTime") - != golden_checkpoint.get("requestedTime") - or extracted_values != golden_checkpoint.get("values") - ): - raise RegressionManifestError( - "Regression golden values are not an exact extraction of its source report." - ) - if output_contract is not None: - report_output_contract = ( - summary.get("outputContract") - if isinstance(summary, Mapping) - else None - ) - if ( - not isinstance(report_output_contract, Mapping) - or report_output_contract.get("sha256") - != output_contract.get("sha256") - ): - raise RegressionManifestError( - "Regression golden output contract does not match its source report." - ) - golden["_sourceReportPath"] = str(report_path) - - golden["_path"] = str(golden_path) - golden["_sha256"] = actual_sha256 - return golden - - -def load_regression_manifest(path: Path | str) -> dict[str, object]: - """Load and validate the stable, machine-independent suite definition.""" - - manifest_path = Path(path).resolve() - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise RegressionManifestError( - f"Could not read regression manifest {manifest_path}: {exc}" - ) from exc - if not isinstance(manifest, dict): - raise RegressionManifestError("Regression manifest must be a JSON object.") - if manifest.get("schemaVersion") != MANIFEST_SCHEMA_VERSION: - raise RegressionManifestError( - f"Unsupported regression manifest schemaVersion: " - f"{manifest.get('schemaVersion')!r}." - ) - - source = manifest.get("source") - if not isinstance(source, dict): - raise RegressionManifestError("Manifest source must be an object.") - raw_source_path = source.get("path") - expected_sha256 = source.get("sha256") - if not isinstance(raw_source_path, str) or not raw_source_path: - raise RegressionManifestError("Manifest source.path must be non-empty.") - if not _valid_sha256(expected_sha256): - raise RegressionManifestError( - "Manifest source.sha256 must be a lowercase SHA-256 digest." - ) - repository_root = _repository_root(manifest_path) - source_path = (repository_root / raw_source_path).resolve() - try: - source_payload = source_path.read_bytes() - except OSError as exc: - raise RegressionManifestError( - f"Could not read authoritative XML {source_path}: {exc}" - ) from exc - actual_sha256 = _sha256(source_payload) - if actual_sha256 != expected_sha256: - raise RegressionManifestError( - "Authoritative XML hash mismatch: " - f"expected {expected_sha256}, received {actual_sha256}." - ) - expected_source_bytes = source.get("bytes") - if not isinstance(expected_source_bytes, int) or expected_source_bytes != len( - source_payload - ): - raise RegressionManifestError( - "Authoritative XML byte count does not match manifest source.bytes." - ) - - reference_archive = source.get("referenceArchive") - reference_archive_path: Path | None = None - reference_archive_sha256: str | None = None - if reference_archive is not None: - if not isinstance(reference_archive, Mapping): - raise RegressionManifestError( - "Manifest source.referenceArchive must be an object when declared." - ) - archive_path_value = reference_archive.get("path") - archive_sha256 = reference_archive.get("sha256") - archive_bytes = reference_archive.get("bytes") - if ( - not isinstance(archive_path_value, str) - or not archive_path_value - or not _valid_sha256(archive_sha256) - or not isinstance(archive_bytes, int) - or archive_bytes <= 0 - or reference_archive.get("role") != "authoritativePhysicalBaseline" - ): - raise RegressionManifestError( - "Manifest AMESim reference archive identity or role is incomplete." - ) - reference_archive_path = (repository_root / archive_path_value).resolve() - if not reference_archive_path.is_relative_to(repository_root): - raise RegressionManifestError( - "AMESim reference archive must remain inside the repository." - ) - try: - archive_payload = reference_archive_path.read_bytes() - except OSError as exc: - raise RegressionManifestError( - f"Could not read AMESim reference archive {reference_archive_path}: {exc}" - ) from exc - if len(archive_payload) != archive_bytes: - raise RegressionManifestError( - "AMESim reference archive byte count mismatch." - ) - actual_archive_sha256 = _sha256(archive_payload) - if actual_archive_sha256 != archive_sha256: - raise RegressionManifestError( - "AMESim reference archive hash mismatch." - ) - reference_archive_sha256 = str(archive_sha256) - - companion = source.get("companionProject") - companion_path: Path | None = None - if companion is not None: - if not isinstance(companion, dict): - raise RegressionManifestError( - "Manifest source.companionProject must be an object when declared." - ) - companion_path_value = companion.get("path") - companion_sha256 = companion.get("sha256") - companion_bytes = companion.get("bytes") - if not isinstance(companion_path_value, str) or not companion_path_value: - raise RegressionManifestError( - "Manifest companion project path must be non-empty." - ) - if not _valid_sha256(companion_sha256): - raise RegressionManifestError( - "Manifest companion project sha256 must be a lowercase digest." - ) - companion_path = (repository_root / companion_path_value).resolve() - try: - companion_payload = companion_path.read_bytes() - except OSError as exc: - raise RegressionManifestError( - f"Could not read companion project {companion_path}: {exc}" - ) from exc - if _sha256(companion_payload) != companion_sha256: - raise RegressionManifestError("Companion project hash mismatch.") - if not isinstance(companion_bytes, int) or companion_bytes != len( - companion_payload - ): - raise RegressionManifestError("Companion project byte count mismatch.") - if companion.get("executionInput") is not False: - raise RegressionManifestError( - "Companion project must be explicitly marked as non-execution input." - ) - - historical_reports = manifest.get("historicalReports", []) - if not isinstance(historical_reports, list): - raise RegressionManifestError("historicalReports must be a list.") - for index, historical in enumerate(historical_reports): - if not isinstance(historical, Mapping): - raise RegressionManifestError( - f"historicalReports[{index}] must be an object." - ) - historical_path_value = historical.get("path") - historical_sha256 = historical.get("sha256") - historical_source_sha256 = historical.get("sourceXmlSha256") - if ( - historical.get("status") != "historicalOnly" - or not isinstance(historical_path_value, str) - or not historical_path_value - or not _valid_sha256(historical_sha256) - or not _valid_sha256(historical_source_sha256) - or historical_source_sha256 == expected_sha256 - or historical.get("compatibleWithCurrentSource") is not False - or not isinstance(historical.get("reason"), str) - or not historical.get("reason") - ): - raise RegressionManifestError( - f"historicalReports[{index}] is incomplete." - ) - historical_path = (repository_root / historical_path_value).resolve() - try: - historical_payload = historical_path.read_bytes() - except OSError as exc: - raise RegressionManifestError( - f"Could not read historical report {historical_path}: {exc}" - ) from exc - if _sha256(historical_payload) != historical_sha256: - raise RegressionManifestError( - f"historicalReports[{index}] hash mismatch." - ) - - sequence = manifest.get("sequence") - variants = manifest.get("variants") - lanes = manifest.get("lanes") - execution = manifest.get("execution") - if not isinstance(sequence, list) or not sequence: - raise RegressionManifestError("Manifest sequence must be a non-empty list.") - if not isinstance(variants, dict): - raise RegressionManifestError("Manifest variants must be an object.") - if not isinstance(lanes, dict) or not lanes: - raise RegressionManifestError("Manifest lanes must be a non-empty object.") - if not isinstance(execution, dict): - raise RegressionManifestError("Manifest execution must be an object.") - - previous_stop = -math.inf - for raw_case_id in sequence: - if not isinstance(raw_case_id, str) or raw_case_id not in variants: - raise RegressionManifestError( - f"Sequence entry {raw_case_id!r} has no matching variant." - ) - variant = variants[raw_case_id] - if not isinstance(variant, dict): - raise RegressionManifestError( - f"Variant {raw_case_id!r} must be an object." - ) - stop_time = _finite_positive( - variant.get("stopTime"), field=f"variants.{raw_case_id}.stopTime" - ) - if stop_time <= previous_stop: - raise RegressionManifestError( - "Variant stop times must be strictly increasing in sequence order." - ) - previous_stop = stop_time - soft_timeout = _finite_positive( - variant.get("softTimeoutSeconds"), - field=f"variants.{raw_case_id}.softTimeoutSeconds", - ) - hard_timeout = _finite_positive( - variant.get("hardTimeoutSeconds"), - field=f"variants.{raw_case_id}.hardTimeoutSeconds", - ) - if hard_timeout <= soft_timeout: - raise RegressionManifestError( - f"Variant {raw_case_id!r} hard timeout must exceed its soft timeout." - ) - prediction_eligible = variant.get("useForRuntimePrediction", True) - if not isinstance(prediction_eligible, bool): - raise RegressionManifestError( - f"Variant {raw_case_id!r} useForRuntimePrediction must be boolean." - ) - checkpoint_times = variant.get("checkpointTimes", []) - if not isinstance(checkpoint_times, list): - raise RegressionManifestError( - f"Variant {raw_case_id!r} checkpointTimes must be a list." - ) - for index, checkpoint in enumerate(checkpoint_times): - checkpoint_value = _finite_nonnegative( - checkpoint, - field=( - f"variants.{raw_case_id}.checkpointTimes[{index}]" - ), - ) - if checkpoint_value > stop_time: - raise RegressionManifestError( - f"Variant {raw_case_id!r} checkpoint exceeds stopTime." - ) - - for lane_name, raw_lane in lanes.items(): - if not isinstance(lane_name, str) or not isinstance(raw_lane, dict): - raise RegressionManifestError("Every lane must be a named object.") - sampling_mode = raw_lane.get("samplingMode") - if sampling_mode not in {"source", "fixed"}: - raise RegressionManifestError( - f"Lane {lane_name!r} samplingMode must be source or fixed." - ) - if sampling_mode == "fixed": - _finite_positive( - raw_lane.get("sampleStep"), - field=f"lanes.{lane_name}.sampleStep", - ) - max_step_mode = raw_lane.get("maxStepMode", "source") - if max_step_mode not in {"source", "fixed"}: - raise RegressionManifestError( - f"Lane {lane_name!r} maxStepMode must be source or fixed." - ) - if max_step_mode == "fixed": - _finite_positive( - raw_lane.get("maxStep"), - field=f"lanes.{lane_name}.maxStep", - ) - instrumentation = raw_lane.get("instrumentationMode", "standard") - if instrumentation not in {"off", "standard", "audit"}: - raise RegressionManifestError( - f"Lane {lane_name!r} has an unsupported instrumentationMode." - ) - - correctness = manifest.get("correctness") - if not isinstance(correctness, dict): - raise RegressionManifestError("Manifest correctness must be an object.") - python_golden_role = correctness.get("pythonGoldenRole", "acceptanceGate") - if python_golden_role not in { - "acceptanceGate", - "determinismDiagnosticOnly", - }: - raise RegressionManifestError( - "correctness.pythonGoldenRole must be acceptanceGate or " - "determinismDiagnosticOnly." - ) - physical_baseline_authority = correctness.get("physicalBaselineAuthority") - compare_amesim_on_every_run = correctness.get( - "compareAmesimOnEveryRun", False - ) - if physical_baseline_authority is not None and ( - physical_baseline_authority != "amesim" - or compare_amesim_on_every_run is not True - or reference_archive_path is None - ): - raise RegressionManifestError( - "AMESim physical authority requires a validated reference archive " - "and compareAmesimOnEveryRun=true." - ) - state_relative_tolerance = _finite_nonnegative( - correctness.get("stateRelativeTolerance"), - field="correctness.stateRelativeTolerance", - ) - state_absolute_tolerance = _finite_nonnegative( - correctness.get("stateAbsoluteTolerance", 0.0), - field="correctness.stateAbsoluteTolerance", - ) - checkpoint_time_tolerance = _finite_nonnegative( - correctness.get("checkpointTimeAbsoluteToleranceSeconds", 1.0e-12), - field="correctness.checkpointTimeAbsoluteToleranceSeconds", - ) - - loaded_goldens: dict[str, dict[str, dict[str, object]]] = {} - loaded_physical_state_v21_goldens: dict[ - str, dict[str, dict[str, object]] - ] = {} - for case_id in sequence: - variant = variants[case_id] - assert isinstance(variant, dict) - raw_goldens = variant.get("goldens", {}) - if not isinstance(raw_goldens, dict): - raise RegressionManifestError( - f"Variant {case_id!r} goldens must be an object keyed by lane." - ) - for lane_name, reference in raw_goldens.items(): - if lane_name not in lanes or not isinstance(reference, Mapping): - raise RegressionManifestError( - f"Variant {case_id!r} has an invalid golden lane reference." - ) - if python_golden_role == "determinismDiagnosticOnly" and ( - reference.get("role") != "pythonDeterminismRegression" - or reference.get("affectsPhysicalCorrectness") is not False - ): - raise RegressionManifestError( - f"Variant {case_id!r} Python golden must be marked as a " - "non-physical determinism regression." - ) - golden_path_value = reference.get("path") - golden_sha256 = reference.get("sha256") - if ( - not isinstance(golden_path_value, str) - or not golden_path_value - or not _valid_sha256(golden_sha256) - ): - raise RegressionManifestError( - f"Variant {case_id!r} golden reference is incomplete." - ) - golden_path = (repository_root / golden_path_value).resolve() - if not golden_path.is_relative_to(repository_root): - raise RegressionManifestError( - "Regression golden must remain inside the repository." - ) - golden = load_regression_golden( - golden_path, - expected_sha256=str(golden_sha256), - repository_root=repository_root, - ) - if ( - golden["caseId"] != case_id - or golden["lane"] != lane_name - or golden["sourceXmlSha256"] != expected_sha256 - ): - raise RegressionManifestError( - f"Variant {case_id!r} golden identity does not match the manifest." - ) - tolerance = golden["tolerance"] - assert isinstance(tolerance, Mapping) - if ( - float(tolerance["relative"]) != state_relative_tolerance - or float(tolerance["absolute"]) != state_absolute_tolerance - or float(tolerance["checkpointTimeAbsoluteSeconds"]) - != checkpoint_time_tolerance - ): - raise RegressionManifestError( - f"Variant {case_id!r} golden tolerance differs from the manifest." - ) - expected_times = [float(value) for value in variant["checkpointTimes"]] - golden_times = [ - float(checkpoint["requestedTime"]) - for checkpoint in golden["physicalCheckpoints"] - ] - if expected_times != golden_times: - raise RegressionManifestError( - f"Variant {case_id!r} golden checkpoint times differ from manifest." - ) - loaded_goldens.setdefault(case_id, {})[lane_name] = golden - - raw_physical_state_v21_goldens = variant.get( - "physicalStateV21Goldens", {} - ) - if not isinstance(raw_physical_state_v21_goldens, dict): - raise RegressionManifestError( - f"Variant {case_id!r} physicalStateV21Goldens must be an object " - "keyed by lane." - ) - for lane_name, reference in raw_physical_state_v21_goldens.items(): - if lane_name not in lanes or not isinstance(reference, Mapping): - raise RegressionManifestError( - f"Variant {case_id!r} has an invalid physical-state-v2.1 " - "golden lane reference." - ) - if physical_baseline_authority == "amesim" and ( - reference.get("role") != "amesimPhysicalBaseline" - or reference.get("compareOnEveryRun") is not True - ): - raise RegressionManifestError( - f"Variant {case_id!r} physical-state reference must be marked " - "as the per-run AMESim physical baseline." - ) - golden_path_value = reference.get("path") - golden_sha256 = reference.get("sha256") - if ( - not isinstance(golden_path_value, str) - or not golden_path_value - or not _valid_sha256(golden_sha256) - ): - raise RegressionManifestError( - f"Variant {case_id!r} physical-state-v2.1 golden reference " - "is incomplete." - ) - golden_path = (repository_root / golden_path_value).resolve() - if not golden_path.is_relative_to(repository_root): - raise RegressionManifestError( - "Physical-state-v2.1 golden must remain inside the repository." - ) - try: - physical_state_golden = load_physical_state_v21_golden(golden_path) - except (OSError, PhysicalStateV21Error) as exc: - raise RegressionManifestError( - f"Could not load physical-state-v2.1 golden {golden_path}: {exc}" - ) from exc - if physical_state_golden.get("_sha256") != golden_sha256: - raise RegressionManifestError( - f"Variant {case_id!r} physical-state-v2.1 golden hash mismatch." - ) - if ( - physical_state_golden.get("caseId") != case_id - or physical_state_golden.get("lane") != lane_name - or physical_state_golden.get("sourceXmlSha256") != expected_sha256 - ): - raise RegressionManifestError( - f"Variant {case_id!r} physical-state-v2.1 golden identity " - "does not match the manifest." - ) - physical_provenance = physical_state_golden.get("provenance") - physical_amesim = ( - physical_provenance.get("amesim") - if isinstance(physical_provenance, Mapping) - else None - ) - if physical_baseline_authority == "amesim" and ( - not isinstance(physical_amesim, Mapping) - or physical_amesim.get("archiveSha256") - != reference_archive_sha256 - ): - raise RegressionManifestError( - f"Variant {case_id!r} AMESim baseline provenance does not " - "match source.referenceArchive." - ) - expected_times = [float(value) for value in variant["checkpointTimes"]] - physical_state_checkpoints = physical_state_golden.get("checkpoints") - assert isinstance(physical_state_checkpoints, list) - golden_times = [ - float(checkpoint["requestedTime"]) - for checkpoint in physical_state_checkpoints - if isinstance(checkpoint, Mapping) - ] - if expected_times != golden_times: - raise RegressionManifestError( - f"Variant {case_id!r} physical-state-v2.1 golden checkpoint " - "times differ from manifest." - ) - loaded_physical_state_v21_goldens.setdefault(case_id, {})[ - lane_name - ] = physical_state_golden - - _finite_positive( - execution.get("predictionSafetyFactor", 1.0), - field="execution.predictionSafetyFactor", - ) - _finite_positive( - execution.get("terminationGraceSeconds", 5.0), - field="execution.terminationGraceSeconds", - ) - if source.get("simulation") != source_simulation_config(source_payload): - raise RegressionManifestError( - "Manifest source.simulation does not match the authoritative XML." - ) - - manifest["_manifestPath"] = str(manifest_path) - manifest["_repositoryRoot"] = str(repository_root) - manifest["_sourcePath"] = str(source_path) - manifest["_referenceArchivePath"] = ( - str(reference_archive_path) if reference_archive_path is not None else None - ) - manifest["_companionPath"] = ( - str(companion_path) if companion_path is not None else None - ) - manifest["_goldens"] = loaded_goldens - manifest["_physicalStateV21Goldens"] = loaded_physical_state_v21_goldens - return manifest - - -def _simulation_element(root: ET.Element) -> ET.Element: - simulations = root.findall("./Simulation") - if len(simulations) != 1: - raise RegressionManifestError( - f"Expected exactly one System/Simulation element, received {len(simulations)}." - ) - return simulations[0] - - -def source_simulation_config(payload: bytes) -> dict[str, object]: - """Read the source settings without importing or invoking the simulator.""" - - try: - root = ET.fromstring(payload) - except ET.ParseError as exc: - raise RegressionManifestError(f"Authoritative XML is not well formed: {exc}") from exc - simulation = _simulation_element(root) - try: - return { - "tStart": float(simulation.attrib["tStart"]), - "tStop": float(simulation.attrib["tStop"]), - "sampleStep": float(simulation.attrib["sampleStep"]), - "maxStep": float(simulation.attrib["maxStep"]), - "method": simulation.attrib["method"], - } - except (KeyError, ValueError) as exc: - raise RegressionManifestError( - "Authoritative XML has an incomplete Simulation configuration." - ) from exc - - -def derive_simulation_xml( - source_payload: bytes, - *, - stop_time: float, - sample_step: float, - max_step: float | None = None, -) -> bytes: - """Return an in-memory derivative with explicit, audited setting overrides.""" - - stop_value = _finite_positive(stop_time, field="stop_time") - sample_value = _finite_positive(sample_step, field="sample_step") - max_value = ( - _finite_positive(max_step, field="max_step") - if max_step is not None - else None - ) - try: - root = ET.fromstring(source_payload) - except ET.ParseError as exc: - raise RegressionManifestError(f"Authoritative XML is not well formed: {exc}") from exc - simulation = _simulation_element(root) - original_attributes = dict(simulation.attrib) - simulation.set("tStop", format(stop_value, ".17g")) - simulation.set("sampleStep", format(sample_value, ".17g")) - if max_value is not None: - simulation.set("maxStep", format(max_value, ".17g")) - changed_attributes = { - key - for key in set(original_attributes) | set(simulation.attrib) - if original_attributes.get(key) != simulation.attrib.get(key) - } - if not changed_attributes.issubset({"tStop", "sampleStep", "maxStep"}): - raise AssertionError( - "In-memory regression derivative changed unexpected Simulation attributes." - ) - return ET.tostring(root, encoding="utf-8", xml_declaration=True) - - -def _package_version(distribution: str) -> str | None: - try: - return importlib.metadata.version(distribution) - except importlib.metadata.PackageNotFoundError: - return None - - -def _repository_snapshot() -> dict[str, object]: - repository_root = Path(__file__).resolve().parents[2] - - def git_output(*arguments: str) -> str | None: - try: - completed = subprocess.run( - ("git", "-C", str(repository_root), *arguments), - check=False, - capture_output=True, - text=True, - timeout=5.0, - ) - except (OSError, subprocess.SubprocessError): - return None - if completed.returncode != 0: - return None - return completed.stdout.strip() - - status = git_output("status", "--short", "--untracked-files=all") - return { - "root": str(repository_root), - "head": git_output("rev-parse", "HEAD"), - "branch": git_output("branch", "--show-current"), - "dirty": bool(status) if status is not None else None, - "status": status.splitlines() if status else [], - } - - -def runtime_snapshot() -> dict[str, object]: - return { - "python": sys.version, - "pythonImplementation": platform.python_implementation(), - "executable": sys.executable, - "platform": platform.platform(), - "machine": platform.machine(), - "processor": platform.processor(), - "cpuCount": os.cpu_count(), - "repository": _repository_snapshot(), - "packages": { - "numpy": _package_version("numpy"), - "scipy": _package_version("scipy"), - "lxml": _package_version("lxml"), - }, - "environment": { - "SIMULATIONAPP_PROFILE": os.getenv("SIMULATIONAPP_PROFILE"), - "SIMULATION_ODE_JACOBIAN_MODE": os.getenv( - "SIMULATION_ODE_JACOBIAN_MODE", "scipy" - ), - "SIMULATIONAPP_PROPERTY_CACHE": os.getenv( - "SIMULATIONAPP_PROPERTY_CACHE", "on" - ), - "SIMULATION_CAUSAL_EXECUTOR_V2": os.getenv( - "SIMULATION_CAUSAL_EXECUTOR_V2", "1" - ), - "SIMULATION_CAUSAL_COORDINATE_KERNEL": os.getenv( - "SIMULATION_CAUSAL_COORDINATE_KERNEL", "1" - ), - "SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS": os.getenv( - "SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS", "1" - ), - "SIMULATION_CAUSAL_DIRECT_EQUATION_READERS": os.getenv( - "SIMULATION_CAUSAL_DIRECT_EQUATION_READERS", "1" - ), - "SIMULATION_CAUSAL_FAST_PATH": os.getenv( - "SIMULATION_CAUSAL_FAST_PATH", "1" - ), - "SIMULATION_MECHANICAL_ATOL_MODE": os.getenv( - "SIMULATION_MECHANICAL_ATOL_MODE", "legacy" - ), - }, - } - - -def _peak_rss_bytes() -> tuple[int | None, int | None, str]: - if resource is None: - return None, None, "unavailable" - raw_value = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) - if sys.platform == "darwin": - return raw_value, raw_value, "bytes" - return raw_value * 1024, raw_value, "KiB" - - -def _emit_worker_event(payload: Mapping[str, object]) -> None: - print( - json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str), - flush=True, - ) - - -def _event_trace(diagnostics: Mapping[str, object]) -> dict[str, object]: - signal = diagnostics.get("signal") - integration = diagnostics.get("integration") - signal_times: list[object] = [] - segment_trace: list[dict[str, object]] = [] - mechanical_transition_times: list[float] = [] - mechanical_transition_times_available = True - totals: dict[str, object] = {} - if isinstance(signal, Mapping): - raw_signal_times = signal.get("eventTimes") - if isinstance(raw_signal_times, list): - signal_times = list(raw_signal_times) - if isinstance(integration, Mapping): - raw_totals = integration.get("totals") - if isinstance(raw_totals, Mapping): - totals = dict(raw_totals) - raw_segments = integration.get("segments") - if isinstance(raw_segments, list): - for segment in raw_segments: - if not isinstance(segment, Mapping): - continue - segment_trace.append( - { - key: segment.get(key) - for key in ( - "startTime", - "requestedStopTime", - "simulatedUntil", - "solverStartCount", - "stateTransitionCount", - "stateTransitionTimes", - "recoverableRetryCount", - ) - } - ) - raw_transition_times = segment.get("stateTransitionTimes") - raw_transition_count = segment.get("stateTransitionCount", 0) - transition_count = ( - int(raw_transition_count) - if isinstance(raw_transition_count, (int, float)) - else 0 - ) - if isinstance(raw_transition_times, list): - finite_transition_times = [ - float(value) - for value in raw_transition_times - if isinstance(value, (int, float)) - and math.isfinite(float(value)) - ] - mechanical_transition_times.extend(finite_transition_times) - if len(finite_transition_times) != transition_count: - mechanical_transition_times_available = False - elif transition_count != 0: - mechanical_transition_times_available = False - total_transition_count = totals.get("stateTransitionCount", 0) - if isinstance(total_transition_count, (int, float)) and int( - total_transition_count - ) != len(mechanical_transition_times): - mechanical_transition_times_available = False - return { - "signalEventTimes": signal_times, - "stateTransitionCount": totals.get("stateTransitionCount", 0), - "mechanicalTransitionTimes": mechanical_transition_times, - "solverStartCount": totals.get("solverStartCount", 0), - "segments": segment_trace, - "mechanicalTransitionTimesAvailable": ( - mechanical_transition_times_available - ), - } - - -def _series_health(series: object) -> dict[str, object]: - if not isinstance(series, Mapping): - return { - "seriesCount": 0, - "scalarCount": 0, - "nonfiniteCount": 0, - "timeStrictlyIncreasing": False, - } - scalar_count = 0 - nonfinite_count = 0 - for values in series.values(): - if not isinstance(values, list): - continue - scalar_count += len(values) - for value in values: - if not isinstance(value, (int, float)) or not math.isfinite(float(value)): - nonfinite_count += 1 - times = series.get("time") - time_values = times if isinstance(times, list) else [] - return { - "seriesCount": len(series), - "scalarCount": scalar_count, - "nonfiniteCount": nonfinite_count, - "timeStrictlyIncreasing": all( - float(first) < float(second) - for first, second in zip(time_values, time_values[1:]) - ), - "timeStart": time_values[0] if time_values else None, - "timeEnd": time_values[-1] if time_values else None, - } - - -def _state_checkpoints( - result: Mapping[str, object], - requested_times: Sequence[float], - sample_step: float, -) -> list[dict[str, object]]: - series = result.get("series") - variables = result.get("variables") - if not isinstance(series, Mapping) or not isinstance(variables, list): - return [] - raw_times = series.get("time") - if not isinstance(raw_times, list) or not raw_times: - return [] - state_keys = [ - str(variable["key"]) - for variable in variables - if isinstance(variable, Mapping) - and variable.get("category") == "state" - and isinstance(variable.get("key"), str) - ] - tolerance = max(1.0e-12, 0.51 * float(sample_step)) - checkpoints: list[dict[str, object]] = [] - for requested in requested_times: - index = min( - range(len(raw_times)), - key=lambda candidate: abs(float(raw_times[candidate]) - requested), - ) - actual_time = float(raw_times[index]) - if abs(actual_time - requested) > tolerance: - checkpoints.append( - { - "requestedTime": requested, - "available": False, - "nearestTime": actual_time, - } - ) - continue - values: dict[str, object] = {} - for key in state_keys: - variable_series = series.get(key) - if isinstance(variable_series, list) and index < len(variable_series): - values[key] = variable_series[index] - checkpoints.append( - { - "requestedTime": requested, - "actualTime": actual_time, - "available": True, - "stateValues": values, - } - ) - return checkpoints - - -def _output_contract(result: Mapping[str, object]) -> dict[str, object]: - """Hash output metadata and shape without including any physical values.""" - - raw_variables = result.get("variables") - variables = ( - [dict(variable) for variable in raw_variables if isinstance(variable, Mapping)] - if isinstance(raw_variables, list) - else [] - ) - raw_series = result.get("series") - series_shape = ( - [ - { - "key": str(key), - "length": len(values) if isinstance(values, list) else None, - } - for key, values in raw_series.items() - ] - if isinstance(raw_series, Mapping) - else [] - ) - lengths = [entry["length"] for entry in series_shape] - numeric_lengths = [value for value in lengths if isinstance(value, int)] - contract_payload = { - "variables": variables, - "seriesShape": series_shape, - } - time_entry = next( - (entry for entry in series_shape if entry["key"] == "time"), - None, - ) - return { - "schemaVersion": 1, - "sha256": _canonical_json_sha256(contract_payload), - "variableMetadataSha256": _canonical_json_sha256(variables), - "seriesShapeSha256": _canonical_json_sha256(series_shape), - "variableCount": len(variables), - "seriesKeyCount": len(series_shape), - "sampleCount": time_entry["length"] if time_entry is not None else None, - "seriesLengthsConsistent": ( - bool(numeric_lengths) - and len(numeric_lengths) == len(lengths) - and len(set(numeric_lengths)) == 1 - ), - "containsPhysicalValues": False, - "hashPayload": "variables metadata + ordered series keys/lengths", - } - - -def summarize_simulation_result( - result: Mapping[str, object], - *, - checkpoint_times: Sequence[float], - sample_step: float, -) -> dict[str, object]: - diagnostics = result.get("diagnostics") - diagnostic_mapping = diagnostics if isinstance(diagnostics, Mapping) else {} - final = result.get("final") - checkpoints = _state_checkpoints(result, checkpoint_times, sample_step) - event_trace = _event_trace(diagnostic_mapping) - physical_contract = { - "schemaVersion": 1, - "projectionCategories": ["state"], - "checkpoints": checkpoints, - "eventTrace": event_trace, - "comparisonMode": "numericTolerance", - } - summary = { - "success": bool(result.get("success")), - "status": result.get("status"), - "partial": bool(result.get("partial")), - "message": result.get("message"), - "simulatedUntil": result.get("simulatedUntil"), - "requestedStopTime": result.get("requestedStopTime"), - "variableCount": ( - len(result["variables"]) - if isinstance(result.get("variables"), list) - else 0 - ), - "seriesHealth": _series_health(result.get("series")), - "final": dict(final) if isinstance(final, Mapping) else {}, - "physicalContract": physical_contract, - "outputContract": _output_contract(result), - # Compatibility aliases for schema-v1 report consumers. - "checkpoints": checkpoints, - "diagnostics": dict(diagnostic_mapping), - "eventTrace": event_trace, - } - if physical_state_v21_applicable(result): - summary["physicalStateV21"] = project_physical_state_v21( - result, - checkpoint_times=checkpoint_times, - sample_step=sample_step, - ) - return summary - - -def _worker_control_listener(cancel_event: threading.Event) -> None: - try: - for line in sys.stdin: - if line.strip().lower() == "cancel": - cancel_event.set() - return - except (OSError, ValueError): - return - - -def run_worker(arguments: argparse.Namespace) -> int: - """Execute one real simulation inside the bounded child process.""" - - os.environ["SIMULATIONAPP_PROFILE"] = arguments.instrumentation_mode - source_path = Path(arguments.xml).resolve() - source_payload = source_path.read_bytes() - actual_sha256 = _sha256(source_payload) - if actual_sha256 != arguments.expected_sha256: - raise RegressionManifestError( - "Worker source hash mismatch: " - f"expected {arguments.expected_sha256}, received {actual_sha256}." - ) - derived_xml = derive_simulation_xml( - source_payload, - stop_time=arguments.stop_time, - sample_step=arguments.sample_step, - max_step=arguments.max_step, - ) - cancel_event = threading.Event() - threading.Thread( - target=_worker_control_listener, - args=(cancel_event,), - name="regression-soft-cancel-listener", - daemon=True, - ).start() - - latest_simulated_time: float | None = None - - def progress_callback( - progress: int, - phase: str, - message: str, - simulated_time: float | None = None, - total_time: float | None = None, - ) -> None: - nonlocal latest_simulated_time - if simulated_time is not None and math.isfinite(float(simulated_time)): - latest_simulated_time = max( - float(simulated_time), - latest_simulated_time if latest_simulated_time is not None else -math.inf, - ) - _emit_worker_event( - { - "event": "progress", - "progress": progress, - "phase": phase, - "message": message, - "simulatedTime": simulated_time, - "totalTime": total_time, - "wallSeconds": perf_counter() - wall_started, - } - ) - - wall_started = perf_counter() - cpu_started = process_time() - try: - from app.main import run_system_xml_simulation - - result = run_system_xml_simulation( - derived_xml, - progress_callback=progress_callback, - cancel_check=cancel_event.is_set, - ) - wall_seconds = perf_counter() - wall_started - cpu_seconds = process_time() - cpu_started - simulated_until = result.get("simulatedUntil") - if isinstance(simulated_until, (int, float)) and math.isfinite( - float(simulated_until) - ): - latest_simulated_time = max( - float(simulated_until), - latest_simulated_time if latest_simulated_time is not None else -math.inf, - ) - peak_rss_bytes, peak_rss_raw, peak_rss_raw_unit = _peak_rss_bytes() - summary = summarize_simulation_result( - result, - checkpoint_times=arguments.checkpoint_time, - sample_step=arguments.sample_step, - ) - _emit_worker_event( - { - "event": "result", - "outcome": ( - "completed" - if bool(result.get("success")) - and result.get("status") == "completed" - else str(result.get("status", "failed")) - ), - "sourceSha256": actual_sha256, - "sourcePath": str(source_path), - "derivedConfiguration": { - "stopTime": arguments.stop_time, - "sampleStep": arguments.sample_step, - "maxStep": arguments.max_step, - "lane": arguments.lane, - "sourceXmlUnmodified": True, - }, - "wallSeconds": wall_seconds, - "cpuSeconds": cpu_seconds, - "peakRssBytes": peak_rss_bytes, - "peakRssRaw": peak_rss_raw, - "peakRssRawUnit": peak_rss_raw_unit, - "lastSimulatedTime": latest_simulated_time, - "runtime": runtime_snapshot(), - "summary": summary, - } - ) - return 0 - except BaseException as exc: - peak_rss_bytes, peak_rss_raw, peak_rss_raw_unit = _peak_rss_bytes() - _emit_worker_event( - { - "event": "result", - "outcome": "error", - "errorType": type(exc).__name__, - "message": str(exc), - "wallSeconds": perf_counter() - wall_started, - "cpuSeconds": process_time() - cpu_started, - "peakRssBytes": peak_rss_bytes, - "peakRssRaw": peak_rss_raw, - "peakRssRawUnit": peak_rss_raw_unit, - "lastSimulatedTime": latest_simulated_time, - "runtime": runtime_snapshot(), - } - ) - return 1 - - -def run_bounded_child_process( - command: Sequence[str], - *, - soft_timeout_seconds: float, - hard_timeout_seconds: float, - termination_grace_seconds: float, - environment: Mapping[str, str] | None = None, -) -> dict[str, object]: - """Run a JSON-lines worker with cooperative and forced timeout layers.""" - - soft_timeout = _finite_positive( - soft_timeout_seconds, field="soft_timeout_seconds" - ) - hard_timeout = _finite_positive( - hard_timeout_seconds, field="hard_timeout_seconds" - ) - grace = _finite_positive( - termination_grace_seconds, field="termination_grace_seconds" - ) - if hard_timeout <= soft_timeout: - raise ValueError("hard_timeout_seconds must exceed soft_timeout_seconds.") - - process = subprocess.Popen( - list(command), - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - env=dict(environment) if environment is not None else None, - ) - output_queue: queue.Queue[tuple[str, str | None]] = queue.Queue() - - def read_stream(name: str, stream: Any) -> None: - try: - for line in stream: - output_queue.put((name, line.rstrip("\n"))) - finally: - output_queue.put((name, None)) - - stdout_thread = threading.Thread( - target=read_stream, - args=("stdout", process.stdout), - daemon=True, - ) - stderr_thread = threading.Thread( - target=read_stream, - args=("stderr", process.stderr), - daemon=True, - ) - stdout_thread.start() - stderr_thread.start() - - started_at = monotonic() - soft_cancel_sent = False - hard_timeout_reached = False - worker_result: dict[str, object] | None = None - progress_events: list[dict[str, object]] = [] - stderr_lines: list[str] = [] - - def consume_output() -> None: - nonlocal worker_result - while True: - try: - stream_name, line = output_queue.get_nowait() - except queue.Empty: - return - if line is None: - continue - if stream_name == "stderr": - stderr_lines.append(line) - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - stderr_lines.append(f"[non-json stdout] {line}") - continue - if not isinstance(event, dict): - continue - if event.get("event") == "progress": - progress_events.append(event) - elif event.get("event") == "result": - worker_result = event - - while process.poll() is None: - consume_output() - elapsed = monotonic() - started_at - if not soft_cancel_sent and elapsed >= soft_timeout: - soft_cancel_sent = True - try: - assert process.stdin is not None - process.stdin.write("cancel\n") - process.stdin.flush() - except (BrokenPipeError, OSError, ValueError): - pass - if elapsed >= hard_timeout: - hard_timeout_reached = True - process.terminate() - try: - process.wait(timeout=grace) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=grace) - break - threading.Event().wait(0.02) - - try: - process.wait(timeout=grace) - except subprocess.TimeoutExpired: - hard_timeout_reached = True - process.kill() - process.wait(timeout=grace) - consume_output() - if process.stdin is not None: - try: - process.stdin.close() - except (BrokenPipeError, OSError, ValueError): - pass - stdout_thread.join(timeout=grace) - stderr_thread.join(timeout=grace) - consume_output() - for stream in (process.stdout, process.stderr): - if stream is not None: - stream.close() - - last_simulated_time: float | None = None - for progress in progress_events: - value = progress.get("simulatedTime") - if isinstance(value, (int, float)) and math.isfinite(float(value)): - last_simulated_time = max( - float(value), - last_simulated_time if last_simulated_time is not None else -math.inf, - ) - if worker_result is not None: - value = worker_result.get("lastSimulatedTime") - if isinstance(value, (int, float)) and math.isfinite(float(value)): - last_simulated_time = max( - float(value), - last_simulated_time if last_simulated_time is not None else -math.inf, - ) - - if hard_timeout_reached: - outcome = "hard_timeout" - elif soft_cancel_sent: - outcome = "soft_timeout" - elif worker_result is None: - outcome = "worker_error" - else: - outcome = str(worker_result.get("outcome", "worker_error")) - return { - "outcome": outcome, - "softCancelSent": soft_cancel_sent, - "hardTimeoutReached": hard_timeout_reached, - "orchestrationWallSeconds": monotonic() - started_at, - "returnCode": process.returncode, - "lastSimulatedTime": last_simulated_time, - "progressEventCount": len(progress_events), - "lastProgressEvent": progress_events[-1] if progress_events else None, - "stderrTail": stderr_lines[-40:], - "worker": worker_result, - } - - -def execute_regression_case(request: RegressionCaseRequest) -> dict[str, object]: - command = [ - sys.executable, - "-u", - "-m", - "app.simulation.benchmark_regression", - "--worker", - "--xml", - str(request.source_path), - "--expected-sha256", - request.expected_sha256, - "--lane", - request.lane, - "--stop-time", - format(request.stop_time, ".17g"), - "--sample-step", - format(request.sample_step, ".17g"), - "--max-step", - format(request.max_step, ".17g"), - "--instrumentation-mode", - request.instrumentation_mode, - ] - for checkpoint in request.checkpoint_times: - command.extend(("--checkpoint-time", format(checkpoint, ".17g"))) - environment = os.environ.copy() - environment["PYTHONDONTWRITEBYTECODE"] = "1" - environment.update(dict(request.environment_overrides)) - return run_bounded_child_process( - command, - soft_timeout_seconds=request.soft_timeout_seconds, - hard_timeout_seconds=request.hard_timeout_seconds, - termination_grace_seconds=request.termination_grace_seconds, - environment=environment, - ) - - -def _completed_case(result: Mapping[str, object], stop_time: float) -> bool: - if result.get("outcome") != "completed": - return False - worker = result.get("worker") - if not isinstance(worker, Mapping): - return False - summary = worker.get("summary") - if not isinstance(summary, Mapping): - return False - simulated_until = summary.get("simulatedUntil") - return ( - bool(summary.get("success")) - and summary.get("status") == "completed" - and isinstance(simulated_until, (int, float)) - and math.isclose( - float(simulated_until), - float(stop_time), - rel_tol=0.0, - abs_tol=max(1.0e-12, 8.0 * math.ulp(max(abs(stop_time), 1.0))), - ) - ) - - -def evaluate_regression_golden( - summary: Mapping[str, object] | None, - golden: Mapping[str, object] | None, -) -> dict[str, object]: - """Compare physical values numerically and output shape by contract hash.""" - - if golden is None: - return { - "configured": False, - "evaluated": False, - "passed": None, - "issues": [], - } - provenance = golden["provenance"] - assert isinstance(provenance, Mapping) - source_report = provenance["sourceReport"] - assert isinstance(source_report, Mapping) - tolerance = golden["tolerance"] - layout = golden["physicalLayout"] - assert isinstance(tolerance, Mapping) - assert isinstance(layout, Mapping) - audit: dict[str, object] = { - "configured": True, - "evaluated": summary is not None, - "passed": False, - "goldenId": golden["id"], - "goldenSha256": golden.get("_sha256"), - "goldenPath": golden.get("_path"), - "sourceReport": { - "path": source_report.get("path"), - "sha256": source_report.get("sha256"), - "generatedAt": source_report.get("generatedAt"), - "metadataCompatibility": source_report.get("metadataCompatibility"), - }, - "relativeTolerance": float(tolerance["relative"]), - "absoluteTolerance": float(tolerance["absolute"]), - "checkpointTimeAbsoluteToleranceSeconds": float( - tolerance["checkpointTimeAbsoluteSeconds"] - ), - "stateKeyLayoutSha256": layout["stateKeyLayoutSha256"], - "expectedCheckpointCount": len(golden["physicalCheckpoints"]), - "expectedStateValueCountPerCheckpoint": len(layout["stateKeys"]), - "comparedValueCount": 0, - "maxAbsoluteError": None, - "maxToleranceRatio": None, - "worstValue": None, - "issues": [], - } - if summary is None: - audit["issues"] = ["missingSummaryForGolden"] - return audit - - physical_contract = summary.get("physicalContract") - actual_checkpoints = ( - physical_contract.get("checkpoints") - if isinstance(physical_contract, Mapping) - else summary.get("checkpoints") - ) - expected_checkpoints = golden["physicalCheckpoints"] - state_keys = layout["stateKeys"] - assert isinstance(expected_checkpoints, list) - assert isinstance(state_keys, list) - issues: list[str] = [] - compared_count = 0 - max_absolute_error = 0.0 - max_tolerance_ratio = 0.0 - worst_value: dict[str, object] | None = None - if not isinstance(actual_checkpoints, list) or len(actual_checkpoints) != len( - expected_checkpoints - ): - issues.append("stateCheckpointGoldenCountMismatch") - else: - relative_tolerance = float(tolerance["relative"]) - absolute_tolerance = float(tolerance["absolute"]) - time_tolerance = float(tolerance["checkpointTimeAbsoluteSeconds"]) - for expected_checkpoint, actual_checkpoint in zip( - expected_checkpoints, actual_checkpoints - ): - assert isinstance(expected_checkpoint, Mapping) - if not isinstance(actual_checkpoint, Mapping): - issues.append("stateCheckpointGoldenLayoutMismatch") - continue - requested_time = float(expected_checkpoint["requestedTime"]) - actual_requested = actual_checkpoint.get("requestedTime") - actual_time = actual_checkpoint.get("actualTime") - if ( - not isinstance(actual_requested, (int, float)) - or not isinstance(actual_time, (int, float)) - or not math.isclose( - float(actual_requested), - requested_time, - rel_tol=0.0, - abs_tol=time_tolerance, - ) - or not math.isclose( - float(actual_time), - requested_time, - rel_tol=0.0, - abs_tol=time_tolerance, - ) - ): - issues.append("stateCheckpointGoldenTimeMismatch") - continue - actual_values = actual_checkpoint.get("stateValues") - expected_values = expected_checkpoint["values"] - assert isinstance(expected_values, list) - if not isinstance(actual_values, Mapping) or set(actual_values) != set( - state_keys - ): - issues.append("stateCheckpointGoldenLayoutMismatch") - continue - for key, expected_value in zip(state_keys, expected_values): - actual_value = actual_values[key] - if not isinstance(actual_value, (int, float)) or not math.isfinite( - float(actual_value) - ): - issues.append("stateCheckpointGoldenNonfiniteValue") - continue - expected_numeric = float(expected_value) - actual_numeric = float(actual_value) - absolute_error = abs(actual_numeric - expected_numeric) - allowed_error = absolute_tolerance + relative_tolerance * abs( - expected_numeric - ) - tolerance_ratio = ( - absolute_error / allowed_error - if allowed_error > 0.0 - else 0.0 if absolute_error == 0.0 else math.inf - ) - compared_count += 1 - if absolute_error > max_absolute_error: - max_absolute_error = absolute_error - if tolerance_ratio > max_tolerance_ratio: - max_tolerance_ratio = tolerance_ratio - worst_value = { - "requestedTime": requested_time, - "key": key, - "expected": expected_numeric, - "actual": actual_numeric, - "absoluteError": absolute_error, - "allowedError": allowed_error, - } - if tolerance_ratio > 1.0: - issues.append("stateCheckpointGoldenValueMismatch") - - expected_output_contract = golden.get("outputContract") - actual_output_contract = summary.get("outputContract") - if expected_output_contract is not None: - if not isinstance(actual_output_contract, Mapping): - issues.append("missingOutputContract") - elif actual_output_contract.get("sha256") != expected_output_contract.get( - "sha256" - ): - issues.append("outputContractMismatch") - deduplicated_issues = list(dict.fromkeys(issues)) - audit.update( - { - "passed": not deduplicated_issues, - "comparedValueCount": compared_count, - "maxAbsoluteError": max_absolute_error if compared_count else None, - "maxToleranceRatio": max_tolerance_ratio if compared_count else None, - "worstValue": worst_value, - "issues": deduplicated_issues, - "outputContractExpected": ( - dict(expected_output_contract) - if isinstance(expected_output_contract, Mapping) - else None - ), - "outputContractActual": ( - dict(actual_output_contract) - if isinstance(actual_output_contract, Mapping) - else None - ), - } - ) - return audit - - -def evaluate_physical_state_v21_golden( - summary: Mapping[str, object] | None, - golden: Mapping[str, object] | None, -) -> dict[str, object]: - """Evaluate the algebraic/discrete v2.1 contract when a manifest pins it.""" - - if golden is None: - return { - "configured": False, - "evaluated": False, - "passed": None, - "issues": [], - } - provenance = golden.get("provenance") - amesim_provenance = ( - provenance.get("amesim") if isinstance(provenance, Mapping) else None - ) - alignment = golden.get("amesimAlignmentAtGeneration") - audit: dict[str, object] = { - "configured": True, - "evaluated": False, - "passed": False, - "goldenId": golden.get("id"), - "goldenPath": golden.get("_path"), - "goldenSha256": golden.get("_sha256"), - "amesimArchiveSha256": ( - amesim_provenance.get("archiveSha256") - if isinstance(amesim_provenance, Mapping) - else None - ), - "amesimAlignmentAtGenerationPassed": ( - alignment.get("passed") if isinstance(alignment, Mapping) else None - ), - "issues": [], - "metrics": [], - } - if summary is None: - audit["issues"] = ["missingSummaryForPhysicalStateV21Golden"] - return audit - contract = summary.get("physicalStateV21") - if not isinstance(contract, Mapping): - audit["issues"] = ["missingPhysicalStateV21Contract"] - return audit - audit["evaluated"] = True - try: - evaluation = evaluate_physical_state_v21(contract, golden) - except PhysicalStateV21Error as exc: - audit["issues"] = ["invalidPhysicalStateV21Contract"] - audit["error"] = str(exc) - return audit - audit.update(evaluation) - audit.update( - { - "configured": True, - "evaluated": True, - "goldenId": golden.get("id"), - "goldenPath": golden.get("_path"), - "goldenSha256": golden.get("_sha256"), - "amesimArchiveSha256": ( - amesim_provenance.get("archiveSha256") - if isinstance(amesim_provenance, Mapping) - else None - ), - "amesimAlignmentAtGenerationPassed": ( - alignment.get("passed") if isinstance(alignment, Mapping) else None - ), - } - ) - return audit - - -def _case_correctness_issues( - result: Mapping[str, object], - *, - variant: Mapping[str, object], - correctness: Mapping[str, object], - golden_evaluation: Mapping[str, object] | None = None, -) -> tuple[str, ...]: - """Evaluate structural/event checks and an optional reviewed golden.""" - - worker = result.get("worker") - summary = worker.get("summary") if isinstance(worker, Mapping) else None - if not isinstance(summary, Mapping): - return ("missingWorkerSummary",) - issues: list[str] = [] - health = summary.get("seriesHealth") - if bool(correctness.get("requireFiniteSeries", False)): - if not isinstance(health, Mapping): - issues.append("missingSeriesHealth") - elif int(health.get("nonfiniteCount", -1)) != 0: - issues.append("nonfiniteSeries") - if bool(correctness.get("requireStrictlyIncreasingTimes", False)): - if not isinstance(health, Mapping) or not bool( - health.get("timeStrictlyIncreasing", False) - ): - issues.append("sampleTimesNotStrictlyIncreasing") - stop_time = float(variant["stopTime"]) - if not isinstance(health, Mapping) or not isinstance( - health.get("seriesCount"), int - ) or int(health["seriesCount"]) <= 0: - issues.append("emptySeries") - elif not isinstance(health.get("timeEnd"), (int, float)) or not math.isclose( - float(health["timeEnd"]), - stop_time, - rel_tol=0.0, - abs_tol=max(1.0e-12, 8.0 * math.ulp(max(abs(stop_time), 1.0))), - ): - issues.append("seriesDoesNotReachStopTime") - - checkpoints = summary.get("checkpoints") - expected_checkpoint_times = variant.get("checkpointTimes", []) - if not isinstance(checkpoints, list) or not isinstance( - expected_checkpoint_times, list - ): - issues.append("missingStateCheckpoints") - elif len(checkpoints) != len(expected_checkpoint_times) or any( - not isinstance(checkpoint, Mapping) - or not bool(checkpoint.get("available", False)) - for checkpoint in checkpoints - ): - issues.append("stateCheckpointUnavailable") - elif any( - not isinstance(checkpoint.get("stateValues"), Mapping) - or not checkpoint["stateValues"] - for checkpoint in checkpoints - if isinstance(checkpoint, Mapping) - ): - issues.append("stateCheckpointValuesMissing") - - maximum_residual = correctness.get("maximumScaledResidual") - diagnostics = summary.get("diagnostics") - pressure_flow = ( - diagnostics.get("pressureFlow") - if isinstance(diagnostics, Mapping) - else None - ) - observed_residual = ( - pressure_flow.get("maxScaledResidual") - if isinstance(pressure_flow, Mapping) - else None - ) - if isinstance(maximum_residual, (int, float)): - if not isinstance(observed_residual, (int, float)) or not math.isfinite( - float(observed_residual) - ): - issues.append("missingOrNonfiniteScaledResidual") - elif float(observed_residual) > float(maximum_residual): - issues.append("scaledResidualExceedsLimit") - - expected_signal_times = variant.get("expectedSignalEventTimes", []) - event_trace = summary.get("eventTrace") - actual_signal_times = ( - event_trace.get("signalEventTimes") - if isinstance(event_trace, Mapping) - else None - ) - signal_tolerance = float( - correctness.get("signalEventTimeAbsoluteToleranceSeconds", 1.0e-12) - ) - if not isinstance(expected_signal_times, list) or not isinstance( - actual_signal_times, list - ): - issues.append("missingSignalEventTrace") - elif len(expected_signal_times) != len(actual_signal_times) or any( - not isinstance(actual, (int, float)) - or not math.isclose( - float(actual), - float(expected), - rel_tol=0.0, - abs_tol=signal_tolerance, - ) - for expected, actual in zip(expected_signal_times, actual_signal_times) - ): - issues.append("signalEventTraceMismatch") - segments = event_trace.get("segments") if isinstance(event_trace, Mapping) else None - segment_start_times = ( - [ - segment.get("startTime") - for segment in segments - if isinstance(segment, Mapping) - ] - if isinstance(segments, list) - else [] - ) - if isinstance(expected_signal_times, list) and any( - not any( - isinstance(actual, (int, float)) - and math.isclose( - float(actual), - float(expected), - rel_tol=0.0, - abs_tol=signal_tolerance, - ) - for actual in segment_start_times - ) - for expected in expected_signal_times - ): - issues.append("signalEventSegmentMissing") - - if bool(correctness.get("mechanicalTransitionTimesAvailable", False)): - if not isinstance(event_trace, Mapping) or not bool( - event_trace.get("mechanicalTransitionTimesAvailable", False) - ): - issues.append("mechanicalTransitionTimesUnavailable") - expected_mechanical_times = variant.get("expectedMechanicalTransitionTimes") - if isinstance(expected_mechanical_times, list): - actual_mechanical_times = ( - event_trace.get("mechanicalTransitionTimes") - if isinstance(event_trace, Mapping) - else None - ) - event_tolerance = float( - correctness.get("eventTimeAbsoluteToleranceSeconds", 2.0e-5) - ) - if not isinstance(actual_mechanical_times, list) or len( - actual_mechanical_times - ) != len(expected_mechanical_times) or any( - not isinstance(actual, (int, float)) - or not math.isclose( - float(actual), - float(expected), - rel_tol=0.0, - abs_tol=event_tolerance, - ) - for expected, actual in zip( - expected_mechanical_times, actual_mechanical_times - ) - ): - issues.append("mechanicalTransitionTraceMismatch") - if isinstance(golden_evaluation, Mapping): - raw_golden_issues = golden_evaluation.get("issues") - if isinstance(raw_golden_issues, list): - python_golden_role = correctness.get( - "pythonGoldenRole", "acceptanceGate" - ) - accepted_issues = ( - raw_golden_issues - if python_golden_role == "acceptanceGate" - else [ - issue - for issue in raw_golden_issues - if issue in {"missingOutputContract", "outputContractMismatch"} - ] - ) - issues.extend( - str(issue) for issue in accepted_issues if isinstance(issue, str) - ) - return tuple(dict.fromkeys(issues)) - - -def _case_wall_seconds(result: Mapping[str, object]) -> float | None: - worker = result.get("worker") - if isinstance(worker, Mapping): - value = worker.get("wallSeconds") - if isinstance(value, (int, float)) and float(value) > 0.0: - return float(value) - value = result.get("orchestrationWallSeconds") - if isinstance(value, (int, float)) and float(value) > 0.0: - return float(value) - return None - - -def run_regression_suite( - manifest_path: Path | str = DEFAULT_MANIFEST_PATH, - *, - lane: str = "production", - case_ids: Sequence[str] | None = None, - case_executor: CaseExecutor = execute_regression_case, -) -> dict[str, object]: - """Run requested horizons in order, deferring unsafe downstream work.""" - - manifest = load_regression_manifest(manifest_path) - lanes = manifest["lanes"] - assert isinstance(lanes, dict) - if lane not in lanes: - raise RegressionManifestError(f"Unknown regression lane {lane!r}.") - lane_config = lanes[lane] - assert isinstance(lane_config, dict) - variants = manifest["variants"] - assert isinstance(variants, dict) - sequence = manifest["sequence"] - assert isinstance(sequence, list) - if case_ids is not None: - requested = set(case_ids) - if not requested: - raise RegressionManifestError("No regression variants were selected.") - unknown = requested - set(sequence) - if unknown: - raise RegressionManifestError( - f"Unknown requested variants: {', '.join(sorted(unknown))}." - ) - last_requested_index = max(sequence.index(case_id) for case_id in requested) - selected_sequence = list(sequence[: last_requested_index + 1]) - else: - selected_sequence = list(sequence) - source_path = Path(str(manifest["_sourcePath"])) - source_payload = source_path.read_bytes() - source_config = source_simulation_config(source_payload) - sampling_mode = lane_config["samplingMode"] - sample_step = ( - float(source_config["sampleStep"]) - if sampling_mode == "source" - else float(lane_config["sampleStep"]) - ) - max_step_mode = lane_config.get("maxStepMode", "source") - max_step = ( - float(source_config["maxStep"]) - if max_step_mode == "source" - else float(lane_config["maxStep"]) - ) - execution = manifest["execution"] - assert isinstance(execution, dict) - safety_factor = float(execution.get("predictionSafetyFactor", 1.5)) - termination_grace = float(execution.get("terminationGraceSeconds", 5.0)) - instrumentation_mode = str( - lane_config.get("instrumentationMode", "standard") - ) - expected_sha256 = str(manifest["source"]["sha256"]) # type: ignore[index] - raw_environment = execution.get("environment", {}) - if not isinstance(raw_environment, dict) or not all( - isinstance(key, str) and isinstance(value, str) - for key, value in raw_environment.items() - ): - raise RegressionManifestError("execution.environment must map strings to strings.") - environment_overrides = tuple(sorted(raw_environment.items())) - correctness = manifest["correctness"] - assert isinstance(correctness, dict) - loaded_goldens = manifest.get("_goldens", {}) - assert isinstance(loaded_goldens, dict) - loaded_physical_state_v21_goldens = manifest.get( - "_physicalStateV21Goldens", {} - ) - assert isinstance(loaded_physical_state_v21_goldens, dict) - - case_reports: list[dict[str, object]] = [] - predecessor_completed = True - previous_stop: float | None = None - previous_wall: float | None = None - deferral_reason: str | None = None - - for case_id in selected_sequence: - variant = variants[case_id] - assert isinstance(variant, dict) - stop_time = float(variant["stopTime"]) - soft_timeout = float(variant["softTimeoutSeconds"]) - hard_timeout = float(variant["hardTimeoutSeconds"]) - runtime_prediction_eligible = bool( - variant.get("useForRuntimePrediction", True) - ) - predicted_wall: float | None = None - if previous_stop is not None and previous_wall is not None: - predicted_wall = ( - previous_wall * stop_time / previous_stop * safety_factor - ) - if not predecessor_completed: - deferral_reason = deferral_reason or "predecessorDidNotComplete" - elif predicted_wall is not None and predicted_wall > soft_timeout: - predecessor_completed = False - deferral_reason = "predictedWallExceedsSoftBudget" - - if not predecessor_completed: - case_reports.append( - { - "caseId": case_id, - "stopTime": stop_time, - "sampleStep": sample_step, - "maxStep": max_step, - "lane": lane, - "outcome": "deferred", - "reason": deferral_reason, - "predictedWallSeconds": predicted_wall, - "softTimeoutSeconds": soft_timeout, - "hardTimeoutSeconds": hard_timeout, - "runtimePredictionEligible": runtime_prediction_eligible, - } - ) - continue - - request = RegressionCaseRequest( - case_id=case_id, - source_path=source_path, - expected_sha256=expected_sha256, - lane=lane, - stop_time=stop_time, - sample_step=sample_step, - max_step=max_step, - checkpoint_times=tuple( - float(value) for value in variant.get("checkpointTimes", []) - ), - soft_timeout_seconds=soft_timeout, - hard_timeout_seconds=hard_timeout, - termination_grace_seconds=termination_grace, - instrumentation_mode=instrumentation_mode, - environment_overrides=environment_overrides, - ) - result = case_executor(request) - solver_completed = _completed_case(result, stop_time) - worker = result.get("worker") - summary = worker.get("summary") if isinstance(worker, Mapping) else None - case_goldens = loaded_goldens.get(case_id, {}) - golden = ( - case_goldens.get(lane) - if isinstance(case_goldens, Mapping) - else None - ) - golden_evaluation = evaluate_regression_golden( - summary if isinstance(summary, Mapping) else None, - golden if isinstance(golden, Mapping) else None, - ) - golden_evaluation["role"] = correctness.get( - "pythonGoldenRole", "acceptanceGate" - ) - golden_evaluation["affectsPhysicalCorrectness"] = ( - correctness.get("pythonGoldenRole", "acceptanceGate") - == "acceptanceGate" - ) - raw_python_golden_issues = golden_evaluation.get("issues") - golden_evaluation["acceptanceIssues"] = ( - list(raw_python_golden_issues) - if correctness.get("pythonGoldenRole", "acceptanceGate") - == "acceptanceGate" - and isinstance(raw_python_golden_issues, list) - else [ - issue - for issue in ( - raw_python_golden_issues - if isinstance(raw_python_golden_issues, list) - else [] - ) - if issue in {"missingOutputContract", "outputContractMismatch"} - ] - ) - case_physical_state_v21_goldens = loaded_physical_state_v21_goldens.get( - case_id, {} - ) - physical_state_v21_golden = ( - case_physical_state_v21_goldens.get(lane) - if isinstance(case_physical_state_v21_goldens, Mapping) - else None - ) - physical_state_v21_evaluation = evaluate_physical_state_v21_golden( - summary if isinstance(summary, Mapping) else None, - ( - physical_state_v21_golden - if isinstance(physical_state_v21_golden, Mapping) - else None - ), - ) - correctness_issues = ( - _case_correctness_issues( - result, - variant=variant, - correctness=correctness, - golden_evaluation=golden_evaluation, - ) - if solver_completed - else () - ) - if solver_completed: - raw_v21_issues = physical_state_v21_evaluation.get("issues") - if isinstance(raw_v21_issues, list): - correctness_issues = tuple( - dict.fromkeys( - ( - *correctness_issues, - *( - str(issue) - for issue in raw_v21_issues - if isinstance(issue, str) - ), - ) - ) - ) - report = { - "caseId": case_id, - "stopTime": stop_time, - "sampleStep": sample_step, - "maxStep": max_step, - "lane": lane, - "samplingMode": sampling_mode, - "maxStepMode": max_step_mode, - "runtimePredictionEligible": runtime_prediction_eligible, - "predictedWallSeconds": predicted_wall, - "softTimeoutSeconds": soft_timeout, - "hardTimeoutSeconds": hard_timeout, - **result, - "acceptance": { - "evaluated": solver_completed, - "passed": solver_completed and not correctness_issues, - "issues": list(correctness_issues), - "regressionGolden": golden_evaluation, - "physicalStateV21Golden": physical_state_v21_evaluation, - }, - } - if solver_completed and correctness_issues: - report["outcome"] = "correctness_failed" - case_reports.append(report) - predecessor_completed = solver_completed and not correctness_issues - if predecessor_completed: - if runtime_prediction_eligible: - previous_stop = stop_time - previous_wall = _case_wall_seconds(result) - else: - deferral_reason = "predecessorDidNotComplete" - - public_manifest = { - key: value - for key, value in manifest.items() - if not key.startswith("_") - } - return { - "schemaVersion": REPORT_SCHEMA_VERSION, - "generatedAt": datetime.now(UTC).isoformat(), - "manifestId": manifest.get("id"), - "manifestPath": str(manifest["_manifestPath"]), - "lane": lane, - "laneDescription": lane_config.get("description"), - "source": { - **dict(manifest["source"]), # type: ignore[arg-type] - "resolvedPath": str(source_path), - "companionResolvedPath": manifest.get("_companionPath"), - "bytes": len(source_payload), - "simulation": source_config, - }, - "manifest": public_manifest, - "cases": case_reports, - } - - -def _parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Run progressively bounded System XML regression horizons." - ) - parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST_PATH) - parser.add_argument( - "--lane", - default="production", - help="Manifest lane: production runs approved correctness contracts; solver-only is an explicit coarse-output iteration lane.", - ) - parser.add_argument("--case", action="append", default=[]) - parser.add_argument("--output", type=Path) - parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS) - parser.add_argument("--xml", help=argparse.SUPPRESS) - parser.add_argument("--expected-sha256", help=argparse.SUPPRESS) - parser.add_argument("--stop-time", type=float, help=argparse.SUPPRESS) - parser.add_argument("--sample-step", type=float, help=argparse.SUPPRESS) - parser.add_argument("--max-step", type=float, help=argparse.SUPPRESS) - parser.add_argument( - "--checkpoint-time", - type=float, - action="append", - default=[], - help=argparse.SUPPRESS, - ) - parser.add_argument( - "--instrumentation-mode", - choices=("off", "standard", "audit"), - default="standard", - help=argparse.SUPPRESS, - ) - arguments = parser.parse_args(argv) - if arguments.worker: - missing = [ - name - for name in ( - "xml", - "expected_sha256", - "stop_time", - "sample_step", - "max_step", - ) - if getattr(arguments, name) is None - ] - if missing: - parser.error("Worker arguments missing: " + ", ".join(missing)) - return arguments - - -def main(argv: Sequence[str] | None = None) -> int: - arguments = _parse_arguments(argv) - if arguments.worker: - return run_worker(arguments) - report = run_regression_suite( - arguments.manifest, - lane=arguments.lane, - case_ids=arguments.case or None, - ) - serialized = json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n" - if arguments.output is None: - print(serialized, end="") - else: - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text(serialized, encoding="utf-8") - print(f"Regression report written to {arguments.output.resolve()}") - outcomes = [case.get("outcome") for case in report["cases"]] - if outcomes and all(outcome == "completed" for outcome in outcomes): - return 0 - if outcomes and all( - outcome in {"completed", "deferred"} for outcome in outcomes - ): - return 2 - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/app/simulation/components/amesim/boundary/sources.py b/app/simulation/components/amesim/boundary/sources.py index 44242a8..dc82837 100644 --- a/app/simulation/components/amesim/boundary/sources.py +++ b/app/simulation/components/amesim/boundary/sources.py @@ -1,14 +1,11 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping - from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition - class AmesimPnpl01(AlgebraicComponent): """AMESim PNPL01 zero pneumatic flow source. @@ -16,53 +13,19 @@ class AmesimPnpl01(AlgebraicComponent): solver: it does not prescribe pressure, and only constrains its port mass flow to zero. """ - - MODEL_TYPE = "amesim_pnpl01" - MODEL_VERSION = "0.1.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),) + MODEL_TYPE = 'amesim_pnpl01' + MODEL_VERSION = '0.1.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'),) PARAMETERS = () RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="PNPL01 零气动流边界", - library_id="amesim", - category_id="boundary", - symbol="amesim_pnpl01", - ports=(PortDisplaySpec("port_1", "left", order=10),), - order=10, - ) + DISPLAY = ComponentDisplaySpec(label='PNPL01 零气动流边界', library_id='amesim', category_id='boundary', symbol='amesim_pnpl01', ports=(PortDisplaySpec('port_1', 'left', order=10),), order=10) def __init__(self, name: str) -> None: super().__init__(name=name) self.set_parameter_values({}) - self.port_1 = self.register_declared_port("port_1") + self.port_1 = self.register_declared_port('port_1') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> AmesimPnpl01: + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPnpl01: return cls(name=name) - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return (self.port_1.m_flow,) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:zero_mass_flow", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_1.m_flow",), - role="flow", - value=self.port_1.m_flow, - ), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - if "port_1" in connected_h: - self.port_1.h_outflow = connected_h["port_1"] + EQUATIONS = ({'id': '__MODEL__:zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'},) diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py index f7af1eb..0421b96 100644 --- a/app/simulation/components/amesim/flow/orifices.py +++ b/app/simulation/components/amesim/flow/orifices.py @@ -1,54 +1,19 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - -from functools import lru_cache from collections.abc import Mapping -from math import isclose, log, sqrt, tanh - -from app.simulation.components.amesim.gases import ( - AMESIM_GAS_INDEX_PARAMETER, - normalize_amesim_gas_index, -) +from math import isclose, sqrt +from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index from app.simulation.core.base import AlgebraicComponent -from app.simulation.core.catalog import ( - ComponentDisplaySpec, - ParameterGroupDisplaySpec, - PortDisplaySpec, -) -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterCondition, - ParameterDefinition, - ParameterOption, - ResultVariableDefinition, -) +from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec +from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition from app.simulation.core.medium import GasMedium from app.simulation.core.ports import PortDefinition - - -_FLOW_COEFFICIENT_OPTIONS = ( - ParameterOption(1.0, "Cq"), - ParameterOption(2.0, "Cv"), - ParameterOption(3.0, "Kv"), -) -_FLOWSET_USES_CQ = (ParameterCondition("flowset", (1.0,)),) -_FLOWSET_USES_CV = (ParameterCondition("flowset", (2.0,)),) -_FLOWSET_USES_KV = (ParameterCondition("flowset", (3.0,)),) -_PN_PRESSURE_RATIO_ACCURACY = 0.9999 -_PN_LAMINAR_SMOOTHING_GAIN = 12.0 -_PNVO001_CLOSED_OPENING_ABS_TOL = 1.0e-12 -_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec( - id="flow_coefficient", - label="流量系数", - parameters=("cq", "area", "Cv", "Kv"), - order=10, -) -_PNVO001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec( - id="flow_coefficient", - label="流量系数", - parameters=("cq", "area0", "Cv", "Kv"), - order=10, -) - +_FLOW_COEFFICIENT_OPTIONS = (ParameterOption(1.0, 'Cq'), ParameterOption(2.0, 'Cv'), ParameterOption(3.0, 'Kv')) +_FLOWSET_USES_CQ = (ParameterCondition('flowset', (1.0,)),) +_FLOWSET_USES_CV = (ParameterCondition('flowset', (2.0,)),) +_FLOWSET_USES_KV = (ParameterCondition('flowset', (3.0,)),) +_PNOR001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(id='flow_coefficient', label='流量系数', parameters=('cq', 'area', 'Cv', 'Kv'), order=10) +_PNVO001_FLOW_COEFFICIENT_GROUP = ParameterGroupDisplaySpec(id='flow_coefficient', label='流量系数', parameters=('cq', 'area0', 'Cv', 'Kv'), order=10) class AmesimPnor001(AlgebraicComponent): """AMESim PNOR001 constant-flow-coefficient pneumatic orifice. @@ -56,170 +21,38 @@ class AmesimPnor001(AlgebraicComponent): This public component preserves the PNOR001 catalog/XML contract and uses real-gas pressure-ratio flow with AMESim-style near-equal-pressure smoothing. """ + MODEL_TYPE = 'amesim_pnor001' + MODEL_VERSION = '0.3.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area', 5e-06, label='孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='选择 Cq/面积方式时用于流量计算的有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。')) + RESULT_VARIABLES = (ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=20)) + DISPLAY = ComponentDisplaySpec(label='PNOR001 常系数气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnor001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10, parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,)) - MODEL_TYPE = "amesim_pnor001" - MODEL_VERSION = "0.3.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = True - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "cq", - 0.72, - label="流量系数 Cq", - quantity="dimensionless", - unit="", - minimum=1.0e-10, - maximum=1.0, - description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。", - visible_when=_FLOWSET_USES_CQ, - ), - ParameterDefinition( - "area", - 5.0e-6, - label="孔口面积", - quantity="area", - unit="m2", - minimum=0.0, - maximum=1.0, - description="选择 Cq/面积方式时用于流量计算的有效孔口面积。", - visible_when=_FLOWSET_USES_CQ, - ), - ParameterDefinition( - "Cv", - 0.5, - label="流量系数 Cv", - quantity="dimensionless", - unit="", - minimum=0.0, - description="选择 Cv 方式时使用的英制流量系数。", - visible_when=_FLOWSET_USES_CV, - ), - ParameterDefinition( - "Kv", - 0.4, - label="流量系数 Kv", - quantity="dimensionless", - unit="", - minimum=0.0, - description="选择 Kv 方式时使用的公制流量系数。", - visible_when=_FLOWSET_USES_KV, - ), - ParameterDefinition( - "flowset", - 1.0, - label="流量系数设置", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=3.0, - editor="choice", - options=_FLOW_COEFFICIENT_OPTIONS, - description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。", - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition( - "cm", - label="质量流量参数", - quantity="dimensionless", - unit="", - category="derived", - order=10, - ), - ResultVariableDefinition( - "gasvel", - label="缩流截面气体速度", - quantity="velocity", - unit="m/s", - category="derived", - order=20, - ), - ) - DISPLAY = ComponentDisplaySpec( - label="PNOR001 常系数气动孔口", - library_id="amesim", - category_id="flow", - symbol="amesim_pnor001", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=10, - parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,), - ) - - def __init__( - self, - name: str, - medium: GasMedium, - *, - cq: float = 0.72, - area: float = 5.0e-6, - Cv: float = 0.5, - Kv: float = 0.4, - gi: float = 1.0, - flowset: float = 1.0, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0) -> None: super().__init__(name=name) - self.set_parameter_values( - { - "cq": cq, - "area": area, - "Cv": Cv, - "Kv": Kv, - "gi": gi, - "flowset": flowset, - } - ) + self.set_parameter_values({'cq': cq, 'area': area, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset}) self.medium = medium self.cq = float(cq) self.area = float(area) self.Cv = float(Cv) self.Kv = float(Kv) self.gi = normalize_amesim_gas_index(gi) - self.flowset = self._integer_parameter("flowset", flowset) + self.flowset = self._integer_parameter('flowset', flowset) if self.flowset not in {1, 2, 3}: - raise ValueError("PNOR001 flowset must be 1, 2, or 3.") - - initial_h = medium.specific_enthalpy(medium.T_ref) - self.port_1 = self.register_declared_port("port_1") - self.port_1.h_outflow = initial_h - self.port_2 = self.register_declared_port("port_2") - self.port_2.h_outflow = initial_h - self._connected_h: dict[str, float] = {} + raise ValueError('PNOR001 flowset must be 1, 2, or 3.') + self.port_1 = self.register_declared_port('port_1') + self.port_2 = self.register_declared_port('port_2') @staticmethod def _integer_parameter(name: str, value: float) -> int: rounded = round(value) - if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): - raise ValueError(f"PNOR001 parameter {name} must be an integer value.") + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12): + raise ValueError(f'PNOR001 parameter {name} must be an integer value.') return int(rounded) @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> AmesimPnor001: - return cls( - name=name, - medium=medium, - cq=parameters["cq"], - area=parameters["area"], - Cv=parameters["Cv"], - Kv=parameters["Kv"], - gi=parameters["gi"], - flowset=parameters["flowset"], - ) + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnor001: + return cls(name=name, medium=medium, cq=parameters['cq'], area=parameters['area'], Cv=parameters['Cv'], Kv=parameters['Kv'], gi=parameters['gi'], flowset=parameters['flowset']) @property def effective_cq(self) -> float: @@ -236,7 +69,7 @@ class AmesimPnor001(AlgebraicComponent): @staticmethod def _area_from_cv(Cv: float, cq: float) -> float: water_density = 999.0 - reference_flow_m3_s = Cv * 6.30901964e-5 + reference_flow_m3_s = Cv * 6.30901964e-05 reference_dp_pa = 6894.75729 return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density)) @@ -246,216 +79,7 @@ class AmesimPnor001(AlgebraicComponent): reference_flow_m3_s = Kv / 3600.0 reference_dp_pa = 100000.0 return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density)) - - def _upstream_temperature(self, port_name: str) -> float: - port = self.get_port(port_name) - inlet_h = self._connected_h.get(port_name, port.h_outflow) - return max( - self.medium.temperature_from_pressure_enthalpy( - max(port.p, 1.0), - inlet_h, - ), - 1.0, - ) - - @staticmethod - def _subsonic_mass_flow_parameter( - *, - pressure_ratio: float, - gamma_s: float, - density: float, - upstream_temperature: float, - upstream_pressure: float, - ) -> float: - expansion = ( - pressure_ratio ** (2.0 * gamma_s) - - pressure_ratio ** (1.0 + gamma_s) - ) - return sqrt( - max( - 2.0 - / (1.0 - gamma_s) - * density - * upstream_temperature - / upstream_pressure - * expansion, - 0.0, - ) - ) - - @lru_cache(maxsize=32768) - def _one_way_flow_characteristics( - self, - *, - upstream_pressure: float, - downstream_pressure: float, - upstream_temperature: float, - ) -> tuple[float, float]: - p_up = max(upstream_pressure, 1.0) - p_down = max(min(downstream_pressure, p_up), 0.0) - T_up = max(upstream_temperature, 1.0) - gamma_s = self.medium.isentropic_density_pressure_factor( - p_up, - T_up, - p_down, - ) - gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9) - density = max(self.medium.density(p_up, T_up), 1.0e-12) - pressure_ratio = max(p_down / p_up, 0.0) - critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** ( - 1.0 / (1.0 - gamma_s) - ) - if pressure_ratio <= critical_ratio: - effective_pressure_ratio = critical_ratio - mass_flow_parameter = ( - sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up) - * (2.0 * gamma_s / (gamma_s + 1.0)) - ** (gamma_s / (1.0 - gamma_s)) - ) - gas_velocity = sqrt(2.0 / (1.0 + gamma_s) * p_up / density) - else: - effective_pressure_ratio = pressure_ratio - mass_flow_parameter = self._subsonic_mass_flow_parameter( - pressure_ratio=pressure_ratio, - gamma_s=gamma_s, - density=density, - upstream_temperature=T_up, - upstream_pressure=p_up, - ) - gas_velocity = sqrt( - max( - 2.0 - / (1.0 - gamma_s) - * p_up - / density - * (1.0 - pressure_ratio ** (1.0 - gamma_s)), - 0.0, - ) - ) - - reference = self._subsonic_mass_flow_parameter( - pressure_ratio=_PN_PRESSURE_RATIO_ACCURACY, - gamma_s=gamma_s, - density=density, - upstream_temperature=T_up, - upstream_pressure=p_up, - ) - if mass_flow_parameter > 0.0 and reference > 0.0: - argument = ( - _PN_LAMINAR_SMOOTHING_GAIN - * abs(mass_flow_parameter / reference) - * log(effective_pressure_ratio) - / log(_PN_PRESSURE_RATIO_ACCURACY) - ) - smoothing_factor = tanh(max(argument, 0.0)) - mass_flow_parameter *= smoothing_factor - gas_velocity *= smoothing_factor - return mass_flow_parameter, gas_velocity - - def mass_flow(self, p_1: float, p_2: float) -> float: - if ( - isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8) - or self.effective_area == 0.0 - ): - return 0.0 - if p_1 > p_2: - return self._one_way_mass_flow( - upstream_pressure=p_1, - downstream_pressure=p_2, - upstream_temperature=self._upstream_temperature("port_1"), - ) - return -self._one_way_mass_flow( - upstream_pressure=p_2, - downstream_pressure=p_1, - upstream_temperature=self._upstream_temperature("port_2"), - ) - - def _one_way_mass_flow( - self, - *, - upstream_pressure: float, - downstream_pressure: float, - upstream_temperature: float, - ) -> float: - p_up = max(upstream_pressure, 1.0) - T_up = max(upstream_temperature, 1.0) - mass_flow_parameter, _ = self._one_way_flow_characteristics( - upstream_pressure=p_up, - downstream_pressure=downstream_pressure, - upstream_temperature=T_up, - ) - return ( - self.effective_cq - * self.effective_area - * p_up - * mass_flow_parameter - / sqrt(T_up) - ) - - def component_result_values(self) -> Mapping[str, float]: - p_1 = max(self.port_1.p, 1.0) - p_2 = max(self.port_2.p, 1.0) - if p_1 >= p_2: - upstream_port_name = "port_1" - upstream_pressure = p_1 - downstream_pressure = p_2 - flow_direction = 1.0 - else: - upstream_port_name = "port_2" - upstream_pressure = p_2 - downstream_pressure = p_1 - flow_direction = -1.0 - mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics( - upstream_pressure=upstream_pressure, - downstream_pressure=downstream_pressure, - upstream_temperature=self._upstream_temperature(upstream_port_name), - ) - return { - "cm": mass_flow_parameter, - "gasvel": flow_direction * gas_velocity, - } - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return ( - self.port_1.m_flow + self.port_2.m_flow, - self.port_1.m_flow - self.mass_flow(self.port_1.p, self.port_2.p), - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=( - f"{self.name}.port_1.m_flow", - f"{self.name}.port_2.m_flow", - ), - role="flow", - value=self.port_1.m_flow + self.port_2.m_flow, - ), - EquationResidual( - id=f"{self.name}:pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_1.p", - f"{self.name}.port_2.p", - f"{self.name}.port_1.m_flow", - ), - role="flow", - value=self.port_1.m_flow - - self.mass_flow(self.port_1.p, self.port_2.p), - ), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self._connected_h = dict(connected_h) - self.port_1.h_outflow = connected_h["port_2"] - self.port_2.h_outflow = connected_h["port_1"] - + EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p', '__MODEL__.port_1.m_flow'], 'role': 'flow'}) class AmesimPnvo001FixedOpening(AlgebraicComponent): """Fixed-opening public variant of AMESim PNVO001. @@ -464,193 +88,39 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent): does not support signal simulation, so this model exposes the pneumatic ports and replaces the signal with a normalized `opening` parameter. """ + MODEL_TYPE = 'amesim_pnvo001_fixed' + MODEL_VERSION = '0.2.0' + PORTS = (PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening', 1.0, label='固定开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='固定的归一化阀门开度;0 表示关闭,1 表示完全开启。')) + RESULT_VARIABLES = (ResultVariableDefinition('xv', label='有效开度', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=30)) + DISPLAY = ComponentDisplaySpec(label='PNVO001 固定开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001_fixed', ports=(PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=30, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,)) - MODEL_TYPE = "amesim_pnvo001_fixed" - MODEL_VERSION = "0.2.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = True - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "cq", - 0.72, - label="流量系数 Cq", - quantity="dimensionless", - unit="", - minimum=1.0e-10, - maximum=1.0, - description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。", - visible_when=_FLOWSET_USES_CQ, - ), - ParameterDefinition( - "area0", - 5.0e-6, - label="最大孔口面积", - quantity="area", - unit="m2", - minimum=0.0, - maximum=1.0, - description="阀门完全开启时的最大有效孔口面积。", - visible_when=_FLOWSET_USES_CQ, - ), - ParameterDefinition( - "Cv", - 0.5, - label="最大流量系数 Cv", - quantity="dimensionless", - unit="", - minimum=0.0, - description="选择 Cv 方式时使用的最大英制流量系数。", - visible_when=_FLOWSET_USES_CV, - ), - ParameterDefinition( - "Kv", - 0.4, - label="最大流量系数 Kv", - quantity="dimensionless", - unit="", - minimum=0.0, - description="选择 Kv 方式时使用的最大公制流量系数。", - visible_when=_FLOWSET_USES_KV, - ), - ParameterDefinition( - "flowset", - 1.0, - label="流量系数设置", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=3.0, - editor="choice", - options=_FLOW_COEFFICIENT_OPTIONS, - description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。", - ), - ParameterDefinition( - "opening", - 1.0, - label="固定开度", - quantity="dimensionless", - unit="", - minimum=0.0, - maximum=1.0, - description="固定的归一化阀门开度;0 表示关闭,1 表示完全开启。", - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition( - "xv", - label="有效开度", - quantity="dimensionless", - unit="", - category="derived", - order=10, - ), - ResultVariableDefinition( - "cm", - label="质量流量参数", - quantity="dimensionless", - unit="", - category="derived", - order=20, - ), - ResultVariableDefinition( - "gasvel", - label="缩流截面气体速度", - quantity="velocity", - unit="m/s", - category="derived", - order=30, - ), - ) - DISPLAY = ComponentDisplaySpec( - label="PNVO001 固定开度气动孔口", - library_id="amesim", - category_id="flow", - symbol="amesim_pnvo001_fixed", - ports=( - # AMESim default geometry places port 2 right and port 3 left. - PortDisplaySpec("port_2", "right", order=10), - PortDisplaySpec("port_3", "left", order=20), - ), - order=30, - parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,), - ) - - def __init__( - self, - name: str, - medium: GasMedium, - *, - cq: float = 0.72, - area0: float = 5.0e-6, - Cv: float = 0.5, - Kv: float = 0.4, - gi: float = 1.0, - flowset: float = 1.0, - opening: float = 1.0, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area0: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0, opening: float=1.0) -> None: super().__init__(name=name) - self.set_parameter_values( - { - "cq": cq, - "area0": area0, - "Cv": Cv, - "Kv": Kv, - "gi": gi, - "flowset": flowset, - "opening": opening, - } - ) + self.set_parameter_values({'cq': cq, 'area0': area0, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset, 'opening': opening}) self.medium = medium self.cq = float(cq) self.area0 = float(area0) self.Cv = float(Cv) self.Kv = float(Kv) self.gi = normalize_amesim_gas_index(gi) - self.flowset = self._integer_parameter("flowset", flowset) + self.flowset = self._integer_parameter('flowset', flowset) if self.flowset not in {1, 2, 3}: - raise ValueError("PNVO001 fixed-opening flowset must be 1, 2, or 3.") + raise ValueError('PNVO001 fixed-opening flowset must be 1, 2, or 3.') self.opening = min(1.0, max(0.0, float(opening))) - - initial_h = medium.specific_enthalpy(medium.T_ref) - self.port_2 = self.register_declared_port("port_2") - self.port_2.h_outflow = initial_h - self.port_3 = self.register_declared_port("port_3") - self.port_3.h_outflow = initial_h - self._connected_h: dict[str, float] = {} + self.port_2 = self.register_declared_port('port_2') + self.port_3 = self.register_declared_port('port_3') @staticmethod def _integer_parameter(name: str, value: float) -> int: rounded = round(value) - if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): - raise ValueError(f"PNVO001 fixed-opening parameter {name} must be an integer value.") + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12): + raise ValueError(f'PNVO001 fixed-opening parameter {name} must be an integer value.') return int(rounded) @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> AmesimPnvo001FixedOpening: - return cls( - name=name, - medium=medium, - cq=parameters["cq"], - area0=parameters["area0"], - Cv=parameters["Cv"], - Kv=parameters["Kv"], - gi=parameters["gi"], - flowset=parameters["flowset"], - opening=parameters["opening"], - ) + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnvo001FixedOpening: + return cls(name=name, medium=medium, cq=parameters['cq'], area0=parameters['area0'], Cv=parameters['Cv'], Kv=parameters['Kv'], gi=parameters['gi'], flowset=parameters['flowset'], opening=parameters['opening']) @property def effective_cq(self) -> float: @@ -667,392 +137,35 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent): @property def effective_area(self) -> float: return self.opening * self.maximum_area - - def _upstream_temperature(self, port_name: str) -> float: - port = self.get_port(port_name) - # A component port's h_outflow describes fluid leaving the valve; the - # upstream state comes from the connection on that same physical side. - inlet_h = self._connected_h.get(port_name, port.h_outflow) - return max( - self.medium.temperature_from_pressure_enthalpy( - max(port.p, 1.0), - inlet_h, - ), - 1.0, - ) - - @staticmethod - def _subsonic_mass_flow_parameter( - *, - pressure_ratio: float, - gamma_s: float, - density: float, - upstream_temperature: float, - upstream_pressure: float, - ) -> float: - expansion = ( - pressure_ratio ** (2.0 * gamma_s) - - pressure_ratio ** (1.0 + gamma_s) - ) - return sqrt( - max( - 2.0 - / (1.0 - gamma_s) - * density - * upstream_temperature - / upstream_pressure - * expansion, - 0.0, - ) - ) - - def mass_flow(self, p_2: float, p_3: float) -> float: - if ( - isclose(p_2, p_3, rel_tol=0.0, abs_tol=1.0e-8) - or self.effective_area == 0.0 - ): - return 0.0 - if p_2 > p_3: - return self._one_way_mass_flow( - upstream_pressure=p_2, - downstream_pressure=p_3, - upstream_temperature=self._upstream_temperature("port_2"), - ) - return -self._one_way_mass_flow( - upstream_pressure=p_3, - downstream_pressure=p_2, - upstream_temperature=self._upstream_temperature("port_3"), - ) - - @lru_cache(maxsize=32768) - def _one_way_flow_characteristics( - self, - *, - upstream_pressure: float, - downstream_pressure: float, - upstream_temperature: float, - ) -> tuple[float, float]: - p_up = max(upstream_pressure, 1.0) - p_down = max(min(downstream_pressure, p_up), 0.0) - T_up = max(upstream_temperature, 1.0) - gamma_s = self.medium.isentropic_density_pressure_factor( - p_up, - T_up, - p_down, - ) - gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9) - density = max(self.medium.density(p_up, T_up), 1.0e-12) - pressure_ratio = max(p_down / p_up, 0.0) - critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** ( - 1.0 / (1.0 - gamma_s) - ) - if pressure_ratio <= critical_ratio: - effective_pressure_ratio = critical_ratio - mass_flow_parameter = ( - sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up) - * (2.0 * gamma_s / (gamma_s + 1.0)) - ** (gamma_s / (1.0 - gamma_s)) - ) - gas_velocity = sqrt( - 2.0 / (1.0 + gamma_s) * p_up / density - ) - else: - effective_pressure_ratio = pressure_ratio - mass_flow_parameter = self._subsonic_mass_flow_parameter( - pressure_ratio=pressure_ratio, - gamma_s=gamma_s, - density=density, - upstream_temperature=T_up, - upstream_pressure=p_up, - ) - gas_velocity = sqrt( - max( - 2.0 - / (1.0 - gamma_s) - * p_up - / density - * (1.0 - pressure_ratio ** (1.0 - gamma_s)), - 0.0, - ) - ) - - # AMESim's gas_cm_prc_ applies this factor continuously over the - # complete pressure-ratio range. It is effectively one outside the - # near-equal-pressure region and makes Cm (and vena-contracta - # velocity) approach zero quadratically as the pressure ratio tends - # to one. The reference Cm intentionally reuses the current gamma_s. - reference_mass_flow_parameter = self._subsonic_mass_flow_parameter( - pressure_ratio=_PN_PRESSURE_RATIO_ACCURACY, - gamma_s=gamma_s, - density=density, - upstream_temperature=T_up, - upstream_pressure=p_up, - ) - if mass_flow_parameter > 0.0 and reference_mass_flow_parameter > 0.0: - smoothing_argument = ( - _PN_LAMINAR_SMOOTHING_GAIN - * abs(mass_flow_parameter / reference_mass_flow_parameter) - * log(effective_pressure_ratio) - / log(_PN_PRESSURE_RATIO_ACCURACY) - ) - smoothing_factor = tanh(max(smoothing_argument, 0.0)) - mass_flow_parameter *= smoothing_factor - gas_velocity *= smoothing_factor - return mass_flow_parameter, gas_velocity - - def _one_way_mass_flow( - self, - *, - upstream_pressure: float, - downstream_pressure: float, - upstream_temperature: float, - ) -> float: - p_up = max(upstream_pressure, 1.0) - T_up = max(upstream_temperature, 1.0) - mass_flow_parameter, _gas_velocity = self._one_way_flow_characteristics( - upstream_pressure=p_up, - downstream_pressure=downstream_pressure, - upstream_temperature=T_up, - ) - return ( - self.effective_cq - * self.effective_area - * p_up - * mass_flow_parameter - / sqrt(T_up) - ) - - def component_result_values(self) -> Mapping[str, float]: - p_2 = max(self.port_2.p, 1.0) - p_3 = max(self.port_3.p, 1.0) - if p_2 >= p_3: - upstream_port_name = "port_2" - upstream_pressure = p_2 - downstream_pressure = p_3 - flow_direction = 1.0 - else: - upstream_port_name = "port_3" - upstream_pressure = p_3 - downstream_pressure = p_2 - flow_direction = -1.0 - upstream_temperature = self._upstream_temperature( - upstream_port_name - ) - mass_flow_parameter, gas_velocity = self._one_way_flow_characteristics( - upstream_pressure=upstream_pressure, - downstream_pressure=downstream_pressure, - upstream_temperature=upstream_temperature, - ) - # AMESim reports no vena-contracta velocity while the valve is closed. - # Signal propagation around a step can leave a round-off-sized opening, - # so apply the same numerical-zero convention to this diagnostic only. - if isclose( - self.opening, - 0.0, - rel_tol=0.0, - abs_tol=_PNVO001_CLOSED_OPENING_ABS_TOL, - ): - gas_velocity = 0.0 - return { - "xv": self.opening, - "cm": mass_flow_parameter, - "gasvel": flow_direction * gas_velocity, - } - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return ( - self.port_2.m_flow + self.port_3.m_flow, - self.port_2.m_flow - self.mass_flow(self.port_2.p, self.port_3.p), - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=( - f"{self.name}.port_2.m_flow", - f"{self.name}.port_3.m_flow", - ), - role="flow", - value=self.port_2.m_flow + self.port_3.m_flow, - ), - EquationResidual( - id=f"{self.name}:pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_2.p", - f"{self.name}.port_3.p", - f"{self.name}.port_2.m_flow", - ), - role="flow", - value=self.port_2.m_flow - - self.mass_flow(self.port_2.p, self.port_3.p), - ), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self._connected_h = dict(connected_h) - self.port_2.h_outflow = connected_h["port_3"] - self.port_3.h_outflow = connected_h["port_2"] - + EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.port_3.p', '__MODEL__.port_2.m_flow'], 'role': 'flow'}) class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening): """AMESim PNVO001 signal-controlled pneumatic orifice.""" - - MODEL_TYPE = "amesim_pnvo001" - MODEL_VERSION = "0.2.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = True - # Repeat the exact-sum promise on this concrete subclass deliberately. - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.signal("res", nominal_role="input"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "cq", - 0.72, - label="流量系数 Cq", - quantity="dimensionless", - unit="", - minimum=1.0e-10, - maximum=1.0, - description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。", - visible_when=_FLOWSET_USES_CQ, - ), - ParameterDefinition( - "area0", - 5.0e-6, - label="最大孔口面积", - quantity="area", - unit="m2", - minimum=0.0, - maximum=1.0, - description="阀门完全开启时的最大有效孔口面积。", - visible_when=_FLOWSET_USES_CQ, - ), - ParameterDefinition( - "Cv", - 0.5, - label="最大流量系数 Cv", - quantity="dimensionless", - unit="", - minimum=0.0, - description="选择 Cv 方式时使用的最大英制流量系数。", - visible_when=_FLOWSET_USES_CV, - ), - ParameterDefinition( - "Kv", - 0.4, - label="最大流量系数 Kv", - quantity="dimensionless", - unit="", - minimum=0.0, - description="选择 Kv 方式时使用的最大公制流量系数。", - visible_when=_FLOWSET_USES_KV, - ), - ParameterDefinition( - "flowset", - 1.0, - label="流量系数设置", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=3.0, - editor="choice", - options=_FLOW_COEFFICIENT_OPTIONS, - description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。", - ), - ParameterDefinition( - "opening0", - 1.0, - label="初始开度", - quantity="dimensionless", - unit="", - minimum=0.0, - maximum=1.0, - description="信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。", - ), - ) + MODEL_TYPE = 'amesim_pnvo001' + MODEL_VERSION = '0.2.0' + PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening0', 1.0, label='初始开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。')) RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES - DISPLAY = ComponentDisplaySpec( - label="PNVO001 信号开度气动孔口", - library_id="amesim", - category_id="flow", - symbol="amesim_pnvo001", - ports=( - PortDisplaySpec("res", "left", order=5), - # AMESim default geometry places port 2 right and port 3 left. - PortDisplaySpec("port_2", "right", order=10), - PortDisplaySpec("port_3", "left", order=20), - ), - order=35, - parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,), - ) + DISPLAY = ComponentDisplaySpec(label='PNVO001 信号开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001', ports=(PortDisplaySpec('res', 'left', order=5), PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=35, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,)) - def __init__( - self, - name: str, - medium: GasMedium, - *, - cq: float = 0.72, - area0: float = 5.0e-6, - Cv: float = 0.5, - Kv: float = 0.4, - gi: float = 1.0, - flowset: float = 1.0, - opening0: float = 1.0, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, cq: float=0.72, area0: float=5e-06, Cv: float=0.5, Kv: float=0.4, gi: float=1.0, flowset: float=1.0, opening0: float=1.0) -> None: AlgebraicComponent.__init__(self, name=name) - self.set_parameter_values( - { - "cq": cq, - "area0": area0, - "Cv": Cv, - "Kv": Kv, - "gi": gi, - "flowset": flowset, - "opening0": opening0, - } - ) + self.set_parameter_values({'cq': cq, 'area0': area0, 'Cv': Cv, 'Kv': Kv, 'gi': gi, 'flowset': flowset, 'opening0': opening0}) self.medium = medium self.cq = float(cq) self.area0 = float(area0) self.Cv = float(Cv) self.Kv = float(Kv) self.gi = normalize_amesim_gas_index(gi) - self.flowset = self._integer_parameter("flowset", flowset) + self.flowset = self._integer_parameter('flowset', flowset) if self.flowset not in {1, 2, 3}: - raise ValueError("PNVO001 signal-opening flowset must be 1, 2, or 3.") + raise ValueError('PNVO001 signal-opening flowset must be 1, 2, or 3.') self.opening0 = min(1.0, max(0.0, float(opening0))) - self.res = self.register_declared_port("res") - self.res.signal = self.opening0 - initial_h = medium.specific_enthalpy(medium.T_ref) - self.port_2 = self.register_declared_port("port_2") - self.port_2.h_outflow = initial_h - self.port_3 = self.register_declared_port("port_3") - self.port_3.h_outflow = initial_h - self._connected_h: dict[str, float] = {} + self.res = self.register_declared_port('res') + self.port_2 = self.register_declared_port('port_2') + self.port_3 = self.register_declared_port('port_3') @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> "AmesimPnvo001SignalOpening": + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnvo001SignalOpening': return cls(name=name, medium=medium, **dict(parameters)) - - @property - def opening(self) -> float: - return min(1.0, max(0.0, self.res.signal)) + EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.port_3.p', '__MODEL__.port_2.m_flow'], 'role': 'flow'}) diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index 2f5a37b..d8f1193 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -1,157 +1,16 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from functools import lru_cache -from math import isclose, isfinite, log, log10, pi, sqrt, tanh - -from app.simulation.components.amesim.gases import ( - AMESIM_GAS_INDEX_PARAMETER, - normalize_amesim_gas_index, -) +from collections.abc import Mapping +from math import isclose, pi +from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index from app.simulation.core.base import AlgebraicComponent, DynamicComponent, ThermodynamicVolumeComponent -from app.simulation.core.catalog import ( - ComponentDisplaySpec, - ParameterGroupDisplaySpec, - PortDisplaySpec, -) -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterCondition, - ParameterDefinition, - ParameterOption, - ResultVariableDefinition, - THERMODYNAMIC_VOLUME_RESULT_VARIABLES, -) -from app.simulation.core.medium import ( - GasMedium, - ThermodynamicProperties, - ThermodynamicPropertiesLinearization, -) +from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec +from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES +from app.simulation.core.medium import GasMedium from app.simulation.core.ports import PortDefinition -from app.simulation.core.state import VolumeState - - -@dataclass(frozen=True) -class Pnl0001MassFlowLinearization: - value: float - partial_p_1: float - partial_p_2: float - partial_temperature: float - valid: bool = True - reason: str | None = None - direction: str = "forward" - - -@dataclass(frozen=True) -class Pnl0001DerivativeLinearization: - derivative: tuple[float, float] - tangents: tuple[tuple[float, ...], tuple[float, ...]] - properties: ThermodynamicPropertiesLinearization - valid: bool = True - reason: str | None = None - - -_MAX_REPORTED_FRICTION_FACTOR = 64_000_000.0 - -# ``pn2pipefr`` blends the laminar and turbulent friction branches over a -# much wider Reynolds range than the former hard 2300..4000 interpolation. -# These constants are a uniform-Re least-squares fit to the unchanged -# Simcenter Amesim 2404 test_mql result for PNL00R/PNL0001/PNL0003 at both -# rr=0.045/14 and rr=0.045/20. The shifted Hill exponent is greater than 2, -# so its zero-valued join to the laminar branch is C2 continuous. -_PN2PIPEFR_TRANSITION_START_REYNOLDS = 89.96829989 -_PN2PIPEFR_TRANSITION_SCALE_REYNOLDS = 2741.96700831 -_PN2PIPEFR_TRANSITION_SHARPNESS = 8.37293695 - -# PNL00R keeps the exact closed-form laminar solve where the fitted -# transition contribution is below four parts per million. Extending this -# shortcut to Re=2300 bypassed the real AMESim transition and introduced a -# solver-visible branch point. -_PN2PIPEFR_ANALYTIC_LAMINAR_MAX_REYNOLDS = 1000.0 - - -def _pn2pipefr_friction_factor_with_precomputed_fully_rough( - reynolds_number: float, - *, - relative_roughness: float, - fully_rough: float | None, -) -> float: - """Evaluate the built-in friction law with its rr-only term supplied.""" - - if reynolds_number <= 0.0: - return 64_000_000.0 - laminar = 64.0 / reynolds_number - if reynolds_number <= _PN2PIPEFR_TRANSITION_START_REYNOLDS: - return laminar - - # Keep this arithmetic in the same order as AmesimPnl00r.friction_factor. - # The specialized fixed-point path only moves the rr-only logarithm out of - # the iteration; subclasses continue to use the public virtual method. - smooth_turbulent = 1.0 / ( - -1.8 * log10(6.9 / reynolds_number) - ) ** 2 - if relative_roughness <= 0.0: - turbulent = smooth_turbulent - else: - assert fully_rough is not None - roughness_reynolds = reynolds_number * relative_roughness - roughness_reynolds_squared = roughness_reynolds * roughness_reynolds - roughness_weight = roughness_reynolds_squared / ( - roughness_reynolds_squared + 180.0 * 180.0 - ) - turbulent = smooth_turbulent + roughness_weight * ( - fully_rough - smooth_turbulent - ) - transition_coordinate = ( - (reynolds_number - _PN2PIPEFR_TRANSITION_START_REYNOLDS) - / _PN2PIPEFR_TRANSITION_SCALE_REYNOLDS - ) - transition_power = transition_coordinate**_PN2PIPEFR_TRANSITION_SHARPNESS - transition_weight = transition_power / (1.0 + transition_power) - return laminar + transition_weight * (turbulent - laminar) - - -def _reported_friction_factor(value: float) -> float: - return min(float(value), _MAX_REPORTED_FRICTION_FACTOR) - - -def _bare_pipe_mass_flow_parameter( - mass_flow: float, - *, - area: float, - diameter: float, - upstream_pressure: float, - upstream_temperature: float, - resistance_length: float, - friction_factor: float, -) -> float: - """Return Amesim's ``Cm`` without the pipe flow coefficient ``Cq``.""" - - flow_coefficient = sqrt( - diameter / (resistance_length * friction_factor) - ) - return ( - abs(mass_flow) - * sqrt(upstream_temperature) - / max( - flow_coefficient * area * upstream_pressure, - 1.0e-18, - ) - ) - - -_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition("mode", (1.0,)) -_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition("mode", (2.0,)) -_DYNAMIC_PIPE_PARAMETER_GROUPS = ( - ParameterGroupDisplaySpec( - id="thermodynamics", - label="热力学", - parameters=("k", "kth", "extemp"), - order=10, - ), -) - +_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition('mode', (1.0,)) +_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition('mode', (2.0,)) +_DYNAMIC_PIPE_PARAMETER_GROUPS = (ParameterGroupDisplaySpec(id='thermodynamics', label='热力学', parameters=('k', 'kth', 'extemp'), order=10),) class AmesimPnl00r(AlgebraicComponent): """AMESim PNL00R pneumatic pipe friction resistance. @@ -160,543 +19,49 @@ class AmesimPnl00r(AlgebraicComponent): the `pn2pipefr` compressible gas relation with a Reynolds/roughness- dependent equivalent flow coefficient. """ + MODEL_TYPE = 'amesim_pnl00r' + MODEL_VERSION = '0.3.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='参与摩擦压降计算的管路有效长度。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。')) + RESULT_VARIABLES = (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=30), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=40)) + DISPLAY = ComponentDisplaySpec(label='PNL00R 气动管路阻力', library_id='amesim', category_id='flow', symbol='amesim_pnl00r', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20) - MODEL_TYPE = "amesim_pnl00r" - MODEL_VERSION = "0.3.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = True - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "diam", - 0.01, - label="管径", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - description="管路的有效内径,用于计算流通面积和摩擦压降。", - ), - ParameterDefinition( - "le", - 1.0, - label="管长", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - description="参与摩擦压降计算的管路有效长度。", - ), - ParameterDefinition( - "rr", - 1.0e-5, - label="相对粗糙度", - quantity="dimensionless", - unit="", - minimum=0.0, - maximum=0.1, - description="管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。", - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition( - "re", - label="Reynolds 数", - quantity="dimensionless", - unit="", - category="derived", - order=10, - ), - ResultVariableDefinition( - "cm", - label="质量流量参数", - quantity="dimensionless", - unit="", - category="derived", - order=20, - ), - ResultVariableDefinition( - "v", - label="平均气体速度", - quantity="velocity", - unit="m/s", - category="derived", - order=30, - ), - ResultVariableDefinition( - "ff", - label="摩擦因子", - quantity="dimensionless", - unit="", - category="derived", - order=40, - ), - ) - DISPLAY = ComponentDisplaySpec( - label="PNL00R 气动管路阻力", - library_id="amesim", - category_id="flow", - symbol="amesim_pnl00r", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=20, - ) - - def __init__( - self, - name: str, - medium: GasMedium, - *, - diam: float = 0.01, - le: float = 1.0, - rr: float = 1.0e-5, - gi: float = 1.0, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, gi: float=1.0) -> None: super().__init__(name=name) - self.set_parameter_values({"diam": diam, "le": le, "rr": rr, "gi": gi}) + self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'gi': gi}) self.medium = medium self.diam = float(diam) self.le = float(le) self.rr = float(rr) self.gi = normalize_amesim_gas_index(gi) self.area = pi * self.diam * self.diam / 4.0 - - initial_h = medium.specific_enthalpy(medium.T_ref) - self.port_1 = self.register_declared_port("port_1") - self.port_1.h_outflow = initial_h - self.port_2 = self.register_declared_port("port_2") - self.port_2.h_outflow = initial_h - # A zero-volume two-port transports its stream outflow from the - # opposite connection, so ``port.h_outflow`` is deliberately crossed. - # Pressure loss, however, needs the enthalpy arriving at the same-side - # upstream connection. Keep that reference separate from the public - # connector outflow state. - self._connected_h = { - "port_1": initial_h, - "port_2": initial_h, - } + self.port_1 = self.register_declared_port('port_1') + self.port_2 = self.register_declared_port('port_2') @staticmethod def _integer_parameter(name: str, value: float) -> int: rounded = round(value) - if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): - raise ValueError(f"PNL00R parameter {name} must be an integer value.") + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12): + raise ValueError(f'PNL00R parameter {name} must be an integer value.') return int(rounded) @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> AmesimPnl00r: - return cls( - name=name, - medium=medium, - diam=parameters["diam"], - le=parameters["le"], - rr=parameters["rr"], - gi=parameters["gi"], - ) - - def _port_temperature(self, port_name: str) -> float: - port = self.get_port(port_name) - return max( - self.medium.temperature_from_pressure_enthalpy( - max(port.p, 1.0), - self._connected_h[port_name], - ), - 1.0, - ) - - def _dynamic_viscosity(self, temperature_k: float) -> float: - return self.medium.dynamic_viscosity(temperature_k) - - def reynolds_number(self, mass_flow: float, temperature: float) -> float: - viscosity = self._dynamic_viscosity(temperature) - return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) - - def friction_factor(self, reynolds_number: float) -> float: - if reynolds_number <= 0.0: - return 64_000_000.0 - laminar = 64.0 / reynolds_number - if reynolds_number <= _PN2PIPEFR_TRANSITION_START_REYNOLDS: - return laminar - - # pn2pipefr does not apply the fully rough correction at every - # turbulent Reynolds number. Its saved ff curves first follow the - # hydraulically smooth law and approach the rough asymptote as Re*rr - # grows. Keeping those two limits separate reproduces the AMESim - # curves for both 14 mm and 20 mm test_mql pipes; putting both terms - # directly inside one Haaland logarithm over-predicts PNL0002 friction - # by about 23 percent near Re=57,000. - smooth_turbulent = 1.0 / ( - -1.8 * log10(6.9 / reynolds_number) - ) ** 2 - if self.rr <= 0.0: - turbulent = smooth_turbulent - else: - # Nikuradse's fully rough asymptote is the Re-independent limit - # of Colebrook. Haaland's rounded all-regime approximation is - # about 0.2003% high at the test_mql roughness values, enough to - # bias its long high-Re PNL0001 filling transient. - fully_rough = 1.0 / ( - -2.0 * log10(self.rr / 3.7) - ) ** 2 - roughness_reynolds = reynolds_number * self.rr - roughness_weight = roughness_reynolds * roughness_reynolds / ( - roughness_reynolds * roughness_reynolds + 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 darcy_pressure_drop( - self, - mass_flow: float, - *, - density: float, - temperature: float, - ) -> float: - if mass_flow == 0.0: - return 0.0 - reynolds = self.reynolds_number(mass_flow, temperature) - friction = self.friction_factor(reynolds) - velocity = mass_flow / (density * self.area) - magnitude = ( - friction - * (self.le / self.diam) - * density - * velocity - * velocity - / 2.0 - ) - return magnitude if mass_flow > 0.0 else -magnitude - - @lru_cache(maxsize=32768) - def _mass_flow_for_pressure_drop( - self, - pressure_drop: float, - *, - density: float, - temperature: float, - ) -> float: - if pressure_drop <= 0.0: - return 0.0 - upper = 1.0e-9 - while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: - upper *= 10.0 - if upper > 1.0e3: - return 1.0e3 - lower = 0.0 - for _ in range(48): - middle = 0.5 * (lower + upper) - if self.darcy_pressure_drop(middle, density=density, temperature=temperature) < pressure_drop: - lower = middle - else: - upper = middle - return 0.5 * (lower + upper) - - def mass_flow(self, p_1: float, p_2: float) -> float: - if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8): - return 0.0 - pressure_difference = p_1 - p_2 - upstream_temperature = self._port_temperature( - "port_1" if pressure_difference > 0.0 else "port_2" - ) - magnitude = AmesimPnl0001._one_way_pn2pipefr_mass_flow( - self, - upstream_pressure=max(p_1, p_2), - downstream_pressure=min(p_1, p_2), - upstream_temperature=upstream_temperature, - resistance_length=self.le, - max_iterations=64, - analytic_laminar=True, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def component_result_values(self) -> Mapping[str, float]: - m_flow = self.mass_flow(self.port_1.p, self.port_2.p) - upstream_pressure = max(self.port_1.p, self.port_2.p, 1.0) - upstream_temperature = self._port_temperature( - "port_1" if self.port_1.p >= self.port_2.p else "port_2" - ) - density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) - reynolds = self.reynolds_number(m_flow, upstream_temperature) - friction = self.friction_factor(reynolds) - flow_coefficient = sqrt(self.diam / (self.le * friction)) - velocity = m_flow / (density * self.area) - cm = ( - abs(m_flow) - * sqrt(upstream_temperature) - / max(flow_coefficient * self.area * upstream_pressure, 1.0e-18) - ) - return { - "re": reynolds, - "cm": cm, - "v": velocity, - "ff": _reported_friction_factor(friction), - } - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return ( - self.port_1.m_flow + self.port_2.m_flow, - self.port_1.m_flow - self.mass_flow(self.port_1.p, self.port_2.p), - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=( - f"{self.name}.port_1.m_flow", - f"{self.name}.port_2.m_flow", - ), - role="flow", - value=self.port_1.m_flow + self.port_2.m_flow, - ), - EquationResidual( - id=f"{self.name}:pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_1.p", - f"{self.name}.port_2.p", - f"{self.name}.port_1.m_flow", - ), - role="flow", - value=self.port_1.m_flow - - self.mass_flow(self.port_1.p, self.port_2.p), - ), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self._connected_h = dict(connected_h) - self.port_1.h_outflow = connected_h["port_2"] - self.port_2.h_outflow = connected_h["port_1"] - - def update_flow_temperature_references( - self, - connected_h: Mapping[str, float], - ) -> None: - self._connected_h = dict(connected_h) - + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnl00r: + return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], gi=parameters['gi']) + EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p', '__MODEL__.port_1.m_flow'], 'role': 'flow'}) class AmesimPnl0001(ThermodynamicVolumeComponent): """AMESim PNL0001 C-R pneumatic pipe with compressibility and friction.""" + MODEL_TYPE = 'amesim_pnl0001' + MODEL_VERSION = '0.4.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积、储气容积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效长度,用于计算储气容积、换热面积和摩擦压降。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'), ParameterDefinition('k', 1.35, label='多方指数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, maximum=2.0, description='mode=1 多方过程使用的指数;当前公开求解器保留该 AMESim 配置,尚未实现多方指数对状态方程的修正。', visible_when=(_DYNAMIC_PIPE_POLYTROPIC_MODE,)), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='mode=2 带换热过程使用的气体与外部环境对流换热系数。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='mode=2 带换热过程使用的外部环境绝对温度。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('mode', 2.0, label='热模型', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '多方过程'), ParameterOption(2.0, '带换热')), description='AMESim 原始编码:1 为多方过程,2 为带换热。当前公开求解器在多方模式下关闭环境换热,在带换热模式下按换热系数和外部温度计算环境换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对温度。')) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=100), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=110), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=120), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=130)) + DISPLAY = ComponentDisplaySpec(label='PNL0001 C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=30, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS) - MODEL_TYPE = "amesim_pnl0001" - MODEL_VERSION = "0.4.0" - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "diam", - 0.01, - label="管径", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - description="管路的有效内径,用于计算流通面积、储气容积和摩擦压降。", - ), - ParameterDefinition( - "le", - 1.0, - label="管长", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - description="管路的有效长度,用于计算储气容积、换热面积和摩擦压降。", - ), - ParameterDefinition( - "rr", - 1.0e-5, - label="相对粗糙度", - quantity="dimensionless", - unit="", - minimum=0.0, - maximum=0.1, - description="管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。", - ), - ParameterDefinition( - "k", - 1.35, - label="多方指数", - quantity="dimensionless", - unit="", - minimum=0.0, - minimum_exclusive=True, - maximum=2.0, - description=( - "mode=1 多方过程使用的指数;当前公开求解器保留该 AMESim " - "配置,尚未实现多方指数对状态方程的修正。" - ), - visible_when=(_DYNAMIC_PIPE_POLYTROPIC_MODE,), - ), - ParameterDefinition( - "kth", - 0.0, - label="换热系数", - quantity="heat_transfer_coefficient", - unit="W/(m2*K)", - minimum=0.0, - description="mode=2 带换热过程使用的气体与外部环境对流换热系数。", - visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,), - ), - ParameterDefinition( - "extemp", - 293.15, - label="外部温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - description="mode=2 带换热过程使用的外部环境绝对温度。", - visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,), - ), - ParameterDefinition( - "mode", - 2.0, - label="热模型", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=2.0, - editor="choice", - options=( - ParameterOption(1.0, "多方过程"), - ParameterOption(2.0, "带换热"), - ), - description=( - "AMESim 原始编码:1 为多方过程,2 为带换热。当前公开求解器在" - "多方模式下关闭环境换热,在带换热模式下按换热系数和外部温度" - "计算环境换热。" - ), - ), - ParameterDefinition( - "p0", - 100000.0, - label="初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - description="仿真开始时管内气体的绝对压力。", - ), - ParameterDefinition( - "T0", - 293.15, - label="初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - description="仿真开始时管内气体的绝对温度。", - ), - ) - RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + ( - ResultVariableDefinition( - "re", - label="Reynolds 数", - quantity="dimensionless", - unit="", - category="derived", - order=100, - ), - ResultVariableDefinition( - "cm", - label="质量流量参数", - quantity="dimensionless", - unit="", - category="derived", - order=110, - ), - ResultVariableDefinition( - "v", - label="平均气体速度", - quantity="velocity", - unit="m/s", - category="derived", - order=120, - ), - ResultVariableDefinition( - "ff", - label="摩擦因子", - quantity="dimensionless", - unit="", - category="derived", - order=130, - ), - ) - DISPLAY = ComponentDisplaySpec( - label="PNL0001 C-R 动态管路", - library_id="amesim", - category_id="flow", - symbol="amesim_pnl0001", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=30, - parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS, - ) - - def __init__( - self, - name: str, - medium: GasMedium, - *, - diam: float = 0.01, - le: float = 1.0, - rr: float = 1.0e-5, - k: float = 1.35, - kth: float = 0.0, - extemp: float = 293.15, - gi: float = 1.0, - mode: float = 2.0, - p0: float = 100000.0, - T0: float = 293.15, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, k: float=1.35, kth: float=0.0, extemp: float=293.15, gi: float=1.0, mode: float=2.0, p0: float=100000.0, T0: float=293.15) -> None: super().__init__(name=name) - self.set_parameter_values( - { - "diam": diam, - "le": le, - "rr": rr, - "k": k, - "kth": kth, - "extemp": extemp, - "gi": gi, - "mode": mode, - "p0": p0, - "T0": T0, - } - ) + self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'k': k, 'kth': kth, 'extemp': extemp, 'gi': gi, 'mode': mode, 'p0': p0, 'T0': T0}) self.medium = medium self.diam = float(diam) self.le = float(le) @@ -705,982 +70,61 @@ class AmesimPnl0001(ThermodynamicVolumeComponent): self.kth = float(kth) self.extemp = float(extemp) self.gi = normalize_amesim_gas_index(gi) - self.mode = self._integer_parameter("mode", mode) + self.mode = self._integer_parameter('mode', mode) self.p0 = float(p0) self.T0 = float(T0) self.area = pi * self.diam * self.diam / 4.0 self.volume = self.area * self.le self.exchange_area = pi * self.diam * self.le - m0 = medium.density(self.p0, self.T0) * self.volume - U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0) - self.state = VolumeState(m=m0, U=U0) - initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0) - self.port_1 = self.register_declared_port("port_1") - self.port_1.p = self.p0 - self.port_1.h_outflow = initial_h - self.port_2 = self.register_declared_port("port_2") - self.port_2.p = self.p0 - self.port_2.h_outflow = initial_h - self._connected_h: dict[str, float] = {} + self.port_1 = self.register_declared_port('port_1') + self.port_2 = self.register_declared_port('port_2') @staticmethod def _integer_parameter(name: str, value: float) -> int: rounded = round(value) - if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12): - raise ValueError(f"PNL0001 parameter {name} must be an integer value.") + if not isclose(value, rounded, rel_tol=0.0, abs_tol=1e-12): + raise ValueError(f'PNL0001 parameter {name} must be an integer value.') integer = int(rounded) - if name == "mode" and integer not in {1, 2}: - raise ValueError("PNL0001 parameter mode must be one of 1, 2.") + if name == 'mode' and integer not in {1, 2}: + raise ValueError('PNL0001 parameter mode must be one of 1, 2.') return integer @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> "AmesimPnl0001": - return cls( - name=name, - medium=medium, - diam=parameters["diam"], - le=parameters["le"], - rr=parameters["rr"], - k=parameters["k"], - kth=parameters["kth"], - extemp=parameters["extemp"], - gi=parameters["gi"], - mode=parameters["mode"], - p0=parameters["p0"], - T0=parameters["T0"], - ) - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) - self.port_1.h_outflow = props.h - self.port_2.p = props.p - self.port_2.h_outflow = props.h - return props - - def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: - return self.properties() - - def thermal_energy_flow_w(self, temperature: float) -> float: - if self.mode == 1: - return 0.0 - return self.kth * self.exchange_area * (self.extemp - temperature) - - def _dynamic_viscosity(self, temperature_k: float) -> float: - return self.medium.dynamic_viscosity(temperature_k) - - def reynolds_number(self, mass_flow: float, temperature: float) -> float: - viscosity = self._dynamic_viscosity(temperature) - return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) - - def friction_factor(self, reynolds_number: float) -> float: - return AmesimPnl00r.friction_factor(self, reynolds_number) - - def darcy_pressure_drop( - self, - mass_flow: float, - *, - density: float, - temperature: float, - ) -> float: - if mass_flow == 0.0: - return 0.0 - reynolds = self.reynolds_number(mass_flow, temperature) - friction = self.friction_factor(reynolds) - velocity = mass_flow / (density * self.area) - magnitude = ( - friction - * (self.le / self.diam) - * density - * velocity - * velocity - / 2.0 - ) - return magnitude if mass_flow > 0.0 else -magnitude - - def _mass_flow_for_pressure_drop( - self, - pressure_drop: float, - *, - density: float, - temperature: float, - ) -> float: - if pressure_drop <= 0.0: - return 0.0 - upper = 1.0e-9 - while self.darcy_pressure_drop( - upper, - density=density, - temperature=temperature, - ) < pressure_drop: - upper *= 10.0 - if upper > 1.0e3: - return 1.0e3 - lower = 0.0 - for _ in range(48): - middle = 0.5 * (lower + upper) - if self.darcy_pressure_drop( - middle, - density=density, - temperature=temperature, - ) < pressure_drop: - lower = middle - else: - upper = middle - return 0.5 * (lower + upper) - - @lru_cache(maxsize=32768) - def _one_way_pn2pipefr_mass_flow( - self, - *, - upstream_pressure: float, - downstream_pressure: float, - upstream_temperature: float, - resistance_length: float, - max_iterations: int = 16, - analytic_laminar: bool = False, - ) -> float: - """AMESim pn2pipefr-style compressible friction flow.""" - - p_up = max(float(upstream_pressure), 1.0) - p_down = max(min(float(downstream_pressure), p_up), 0.0) - T_up = max(float(upstream_temperature), 1.0) - if resistance_length <= 0.0: - raise ValueError("Pipe resistance length must be positive.") - - gamma_s = self.medium.isentropic_density_pressure_factor( - p_up, - T_up, - p_down, - ) - gamma_s = min(max(gamma_s, 1.0e-9), 1.0 - 1.0e-9) - density = max(self.medium.density(p_up, T_up), 1.0e-12) - pressure_ratio = max(p_down / p_up, 0.0) - critical_ratio = (2.0 * gamma_s / (gamma_s + 1.0)) ** ( - 1.0 / (1.0 - gamma_s) - ) - - def mass_flow_parameter(ratio: float) -> float: - if ratio <= critical_ratio: - value = ( - sqrt(2.0 / (1.0 + gamma_s) * density * T_up / p_up) - * (2.0 * gamma_s / (gamma_s + 1.0)) - ** (gamma_s / (1.0 - gamma_s)) - ) - effective_ratio = critical_ratio - else: - expansion = ratio ** (2.0 * gamma_s) - ratio ** (1.0 + gamma_s) - value = sqrt( - max( - 2.0 - / (1.0 - gamma_s) - * density - * T_up - / p_up - * expansion, - 0.0, - ) - ) - effective_ratio = ratio - - accuracy = 0.9999 - reference_expansion = ( - accuracy ** (2.0 * gamma_s) - - accuracy ** (1.0 + gamma_s) - ) - reference = sqrt( - max( - 2.0 - / (1.0 - gamma_s) - * density - * T_up - / p_up - * reference_expansion, - 0.0, - ) - ) - if value > 0.0 and reference > 0.0: - smoothing_argument = ( - 12.0 - * abs(value / reference) - * log(effective_ratio) - / log(accuracy) - ) - value *= tanh(max(smoothing_argument, 0.0)) - return value - - pressure_ratio_flow_parameter = mass_flow_parameter(pressure_ratio) - if analytic_laminar: - viscosity = self._dynamic_viscosity(T_up) - laminar_mass_flow = ( - self.area * p_up * pressure_ratio_flow_parameter - ) ** 2 / (16.0 * pi * viscosity * resistance_length * T_up) - if ( - self.reynolds_number(laminar_mass_flow, T_up) - <= _PN2PIPEFR_ANALYTIC_LAMINAR_MAX_REYNOLDS - ): - return laminar_mass_flow - - sqrt_temperature = sqrt(T_up) - compiled_reynolds = ( - type(self).reynolds_number is AmesimPnl0001.reynolds_number - and type(self)._dynamic_viscosity is AmesimPnl0001._dynamic_viscosity - ) - reynolds_denominator = ( - pi * self.diam * self._dynamic_viscosity(T_up) - if max_iterations > 0 and compiled_reynolds - else None - ) - compiled_friction_factor = type(self) in ( - AmesimPnl00r, - AmesimPnl0001, - AmesimPnl0002, - ) - fully_rough = ( - 1.0 / (-2.0 * log10(self.rr / 3.7)) ** 2 - if max_iterations > 0 - and compiled_friction_factor - and self.rr > 0.0 - else None - ) - - def target_flow(mass_flow: float) -> float: - reynolds = ( - 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 - * p_up - * pressure_ratio_flow_parameter - / 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): - next_magnitude = target_flow(magnitude) - if abs(next_magnitude - magnitude) <= max( - 1.0e-12, - abs(magnitude) * 1.0e-9, - ): - return next_magnitude - magnitude = 0.5 * (magnitude + next_magnitude) - return magnitude - - def mass_flow(self, p_1: float, p_2: float, temperature: float) -> float: - if isclose(p_1, p_2, rel_tol=0.0, abs_tol=1.0e-8): - return 0.0 - pressure_difference = p_1 - p_2 - upstream_temperature = max(float(temperature), 1.0) - resistance_length = getattr(self, "resistance_length", self.le) - magnitude = self._one_way_pn2pipefr_mass_flow( - upstream_pressure=max(p_1, p_2), - downstream_pressure=min(p_1, p_2), - upstream_temperature=upstream_temperature, - resistance_length=resistance_length, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def linearize_mass_flow( - self, - p_1: float, - p_2: float, - temperature: float, - *, - relative_step: float = 2.0 ** -26, - slope_relative_tolerance: float = 5.0e-3, - ) -> Pnl0001MassFlowLinearization: - """Audit local flow-law slopes without perturbing the full system RHS.""" - - p_1 = float(p_1) - p_2 = float(p_2) - temperature = float(temperature) - direction = "forward" if p_1 > p_2 else "reverse" - value = self.mass_flow(p_1, p_2, temperature) - - def invalid(reason: str) -> Pnl0001MassFlowLinearization: - return Pnl0001MassFlowLinearization( - value=value, - partial_p_1=0.0, - partial_p_2=0.0, - partial_temperature=0.0, - valid=False, - reason=reason, - direction=direction, - ) - - if not all(isfinite(item) for item in (p_1, p_2, temperature, value)): - return invalid("non_finite_primal") - pressure_gap = abs(p_1 - p_2) - if pressure_gap <= 1.0e-8: - return invalid("flow_direction_boundary") - if temperature <= 1.0 * (1.0 + 1.0e-10): - return invalid("temperature_floor_boundary") - if relative_step <= 0.0 or slope_relative_tolerance <= 0.0: - raise ValueError("PNL0001 slope audit tolerances must be positive.") - - pressure_step = min( - relative_step * max(abs(p_1), abs(p_2), 1.0), - 0.25 * pressure_gap, - ) - temperature_step = min( - relative_step * max(abs(temperature), 1.0), - 0.25 * (temperature - 1.0), - ) - if pressure_step <= 0.0 or temperature_step <= 0.0: - return invalid("unresolved_local_step") - - arguments = (p_1, p_2, temperature) - argument_names = ("p_1", "p_2", "temperature") - steps = (pressure_step, pressure_step, temperature_step) - partials: list[float] = [] - for argument_index, (argument, step) in enumerate( - zip(arguments, steps, strict=True) - ): - lower = list(arguments) - upper = list(arguments) - lower[argument_index] = argument - step - upper[argument_index] = argument + step - lower_value = self.mass_flow(*lower) - upper_value = self.mass_flow(*upper) - left_slope = (value - lower_value) / step - right_slope = (upper_value - value) / step - slope_scale = max( - abs(left_slope), - abs(right_slope), - abs(value) / max(abs(argument), 1.0), - 1.0e-12, - ) - if not all( - isfinite(item) - for item in ( - lower_value, - upper_value, - left_slope, - right_slope, - ) - ): - return invalid( - f"non_finite_local_slope:{argument_names[argument_index]}" - ) - if ( - abs(left_slope - right_slope) - > slope_relative_tolerance * slope_scale - ): - return invalid( - f"local_slope_disagreement:{argument_names[argument_index]}" - ) - partials.append(0.5 * (left_slope + right_slope)) - - return Pnl0001MassFlowLinearization( - value=value, - partial_p_1=partials[0], - partial_p_2=partials[1], - partial_temperature=partials[2], - direction=direction, - ) - - def component_result_values(self) -> Mapping[str, float]: - props = self.properties() - flow = self.mass_flow(self.port_1.p, props.p, props.T) - upstream_pressure = max(self.port_1.p, props.p, 1.0) - density = max(self.medium.density(upstream_pressure, props.T), 1.0e-12) - reynolds = self.reynolds_number(flow, props.T) - friction = self.friction_factor(reynolds) - return { - "m": self.state.m, - "U": self.state.U, - "p": props.p, - "T": props.T, - "rho": props.rho, - "u": props.u, - "h": props.h, - "re": reynolds, - "cm": _bare_pipe_mass_flow_parameter( - flow, - area=self.area, - diameter=self.diam, - upstream_pressure=upstream_pressure, - upstream_temperature=props.T, - resistance_length=self.le, - friction_factor=friction, - ), - "v": flow / (density * self.area), - "ff": _reported_friction_factor(friction), - } - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) - return ( - self.port_2.p - props.p, - self.port_1.m_flow - self.mass_flow(self.port_1.p, props.p, props.T), - ) - - def pressure_flow_equation_value_readers( - self, - ) -> Mapping[str, Callable[[], float]]: - """Expose the independently evaluable state-pressure residual.""" - - def pressure_state_residual() -> float: - props = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.volume, - ) - return self.port_2.p - props.p - - return { - f"{self.name}:port_2_pressure_state": pressure_state_residual, - } - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) - return ( - EquationResidual( - id=f"{self.name}:port_2_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_2.p", f"{self.name}.state"), - role="effort", - value=self.port_2.p - props.p, - ), - EquationResidual( - id=f"{self.name}:port_1_pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_1.p", - f"{self.name}.port_2.p", - f"{self.name}.port_1.m_flow", - ), - role="flow", - value=self.port_1.m_flow - - self.mass_flow(self.port_1.p, props.p, props.T), - ), - ) - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - props = self.properties() - inlet_h_1 = self.connection_inlet_enthalpy( - port_m_flow=self.port_1.m_flow, - connected_h=connected_h["port_1"], - internal_h=props.h, - ) - inlet_h_2 = self.connection_inlet_enthalpy( - port_m_flow=self.port_2.m_flow, - connected_h=connected_h["port_2"], - internal_h=props.h, - ) - derivative = VolumeState( - m=self.port_1.m_flow + self.port_2.m_flow, - U=( - self.port_1.m_flow * inlet_h_1 - + self.port_2.m_flow * inlet_h_2 - + self.thermal_energy_flow_w(props.T) - ), - ) - return derivative.as_vector() - - def linearize_state_derivative( - self, - connected_h: Mapping[str, float], - *, - state_mass_tangent: Sequence[float], - state_energy_tangent: Sequence[float], - port_mass_flow_tangents: Mapping[str, Sequence[float]], - connected_h_tangents: Mapping[str, Sequence[float]], - property_linearization: ThermodynamicPropertiesLinearization | None = None, - flow_boundary_tolerance: float = 1.0e-12, - ) -> Pnl0001DerivativeLinearization: - """Linearize the pipe storage balance in a fixed stream mode.""" - - port_names = ("port_1", "port_2") - vectors = { - "state_mass": tuple(float(value) for value in state_mass_tangent), - "state_energy": tuple(float(value) for value in state_energy_tangent), - } - for port_name in port_names: - vectors[f"flow:{port_name}"] = tuple( - float(value) for value in port_mass_flow_tangents[port_name] - ) - vectors[f"enthalpy:{port_name}"] = tuple( - float(value) for value in connected_h_tangents[port_name] - ) - widths = {len(values) for values in vectors.values()} - if len(widths) != 1: - raise ValueError("PNL0001 tangent vectors must have equal lengths.") - width = len(vectors["state_mass"]) - invalid_reason: str | None = None - if not all(isfinite(value) for values in vectors.values() for value in values): - invalid_reason = "non_finite_tangent_input" - - properties = property_linearization or self.medium.linearize_properties_from_mU( - self.state.m, - self.state.U, - self.volume, - vectors["state_mass"], - vectors["state_energy"], - (0.0,) * width, - ) - if properties.tangents.width != width: - raise ValueError( - "PNL0001 property tangent width must match balance tangents." - ) - props = properties.properties - if not properties.valid: - invalid_reason = invalid_reason or properties.reason - - mass_derivative = self.port_1.m_flow + self.port_2.m_flow - energy_derivative = self.thermal_energy_flow_w(props.T) - mass_tangent = [0.0] * width - thermal_coefficient = ( - 0.0 if self.mode == 1 else self.kth * self.exchange_area - ) - energy_tangent = [ - -thermal_coefficient * properties.tangents.T[index] - for index in range(width) - ] - - for port_name in port_names: - port = self.get_port(port_name) - flow_tangent = vectors[f"flow:{port_name}"] - if ( - abs(port.m_flow) <= flow_boundary_tolerance - and any(value != 0.0 for value in flow_tangent) - ): - invalid_reason = invalid_reason or ( - f"flow_direction_boundary:{port_name}" - ) - if port.m_flow > 0.0: - inlet_h = connected_h[port_name] - inlet_h_tangent = vectors[f"enthalpy:{port_name}"] - else: - inlet_h = props.h - inlet_h_tangent = properties.tangents.h - energy_derivative += port.m_flow * inlet_h - for index in range(width): - mass_tangent[index] += flow_tangent[index] - energy_tangent[index] += ( - inlet_h * flow_tangent[index] - + port.m_flow * inlet_h_tangent[index] - ) - - return Pnl0001DerivativeLinearization( - derivative=(mass_derivative, energy_derivative), - tangents=(tuple(mass_tangent), tuple(energy_tangent)), - properties=properties, - valid=invalid_reason is None, - reason=invalid_reason, - ) - + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0001': + return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], k=parameters['k'], kth=parameters['kth'], extemp=parameters['extemp'], gi=parameters['gi'], mode=parameters['mode'], p0=parameters['p0'], T0=parameters['T0']) + EQUATIONS = ({'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_1_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p', '__MODEL__.port_1.m_flow'], 'role': 'flow'}) class AmesimPnl0002(AmesimPnl0001): """AMESim PNL0002 R-C-R pneumatic pipe with one center compliance.""" - - MODEL_TYPE = "amesim_pnl0002" - MODEL_VERSION = "0.6.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = True - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - ) + MODEL_TYPE = 'amesim_pnl0002' + MODEL_VERSION = '0.6.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) PARAMETERS = AmesimPnl0001.PARAMETERS RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES - DISPLAY = ComponentDisplaySpec( - label="PNL0002 R-C-R 动态管路", - library_id="amesim", - category_id="flow", - symbol="amesim_pnl0002", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=40, - parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS, - ) + DISPLAY = ComponentDisplaySpec(label='PNL0002 R-C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0002', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS) @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> "AmesimPnl0002": - return cls( - name=name, - medium=medium, - diam=parameters["diam"], - le=parameters["le"], - rr=parameters["rr"], - k=parameters["k"], - kth=parameters["kth"], - extemp=parameters["extemp"], - gi=parameters["gi"], - mode=parameters["mode"], - p0=parameters["p0"], - T0=parameters["T0"], - ) + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0002': + return cls(name=name, medium=medium, diam=parameters['diam'], le=parameters['le'], rr=parameters['rr'], k=parameters['k'], kth=parameters['kth'], extemp=parameters['extemp'], gi=parameters['gi'], mode=parameters['mode'], p0=parameters['p0'], T0=parameters['T0']) @property def resistance_length(self) -> float: return self.le / 2.0 - - def properties(self) -> ThermodynamicProperties: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) - self.port_1.h_outflow = props.h - self.port_2.h_outflow = props.h - return props - - def darcy_pressure_drop( - self, - mass_flow: float, - *, - density: float, - temperature: float, - ) -> float: - if mass_flow == 0.0: - return 0.0 - reynolds = self.reynolds_number(mass_flow, temperature) - friction = self.friction_factor(reynolds) - velocity = mass_flow / (density * self.area) - magnitude = ( - friction - * (self.resistance_length / self.diam) - * density - * velocity - * velocity - / 2.0 - ) - return magnitude if mass_flow > 0.0 else -magnitude - - def port_mass_flow( - self, - port_pressure: float, - center_pressure: float, - center_temperature: float, - *, - port_name: str | None = None, - ) -> float: - upstream_temperature = center_temperature - if ( - port_name is not None - and port_name in self._connected_h - and port_pressure > center_pressure - ): - inlet_h = self._connected_h[port_name] - upstream_temperature = self.medium.temperature_from_pressure_enthalpy( - max(port_pressure, 1.0), - inlet_h, - ) - return self.mass_flow( - port_pressure, - center_pressure, - max(upstream_temperature, 1.0), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self._connected_h = dict(connected_h) - - def update_flow_temperature_references( - self, - connected_h: Mapping[str, float], - ) -> None: - self._connected_h = dict(connected_h) - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - # Junctions allocate their energy-balanced outlet enthalpy per port. - # The separate cache is only the temperature input to pn2pipefr. - return super().state_derivative_from_ports(connected_h) - - def component_result_values(self) -> Mapping[str, float]: - props = self.properties() - flow_1 = self.port_mass_flow( - self.port_1.p, - props.p, - props.T, - port_name="port_1", - ) - flow_2 = self.port_mass_flow( - self.port_2.p, - props.p, - props.T, - port_name="port_2", - ) - resistance_diagnostics: list[tuple[float, float, float, float]] = [] - for port_name, port, flow in ( - ("port_1", self.port_1, flow_1), - ("port_2", self.port_2, flow_2), - ): - if flow >= 0.0: - upstream_pressure = max(port.p, 1.0) - upstream_h = self._connected_h.get(port_name, props.h) - upstream_temperature = max( - self.medium.temperature_from_pressure_enthalpy( - upstream_pressure, - upstream_h, - ), - 1.0, - ) - else: - upstream_pressure = max(props.p, 1.0) - upstream_temperature = props.T - density = max( - self.medium.density(upstream_pressure, upstream_temperature), - 1.0e-12, - ) - reynolds = self.reynolds_number(flow, upstream_temperature) - friction = self.friction_factor(reynolds) - resistance_diagnostics.append( - ( - reynolds, - _bare_pipe_mass_flow_parameter( - flow, - area=self.area, - diameter=self.diam, - upstream_pressure=upstream_pressure, - upstream_temperature=upstream_temperature, - resistance_length=self.resistance_length, - friction_factor=friction, - ), - abs(flow) / (density * self.area), - friction, - ) - ) - reynolds, cm, velocity, friction = ( - sum(values) / len(resistance_diagnostics) - for values in zip(*resistance_diagnostics) - ) - return { - "m": self.state.m, - "U": self.state.U, - "p": props.p, - "T": props.T, - "rho": props.rho, - "u": props.u, - "h": props.h, - "re": reynolds, - "cm": cm, - "v": velocity, - "ff": _reported_friction_factor(friction), - } - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) - return ( - self.port_1.m_flow - - self.port_mass_flow( - self.port_1.p, - props.p, - props.T, - port_name="port_1", - ), - self.port_2.m_flow - - self.port_mass_flow( - self.port_2.p, - props.p, - props.T, - port_name="port_2", - ), - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.volume) - return ( - EquationResidual( - id=f"{self.name}:port_1_pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_1.p", - f"{self.name}.state", - f"{self.name}.port_1.m_flow", - ), - role="flow", - value=self.port_1.m_flow - - self.port_mass_flow( - self.port_1.p, - props.p, - props.T, - port_name="port_1", - ), - ), - EquationResidual( - id=f"{self.name}:port_2_pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_2.p", - f"{self.name}.state", - f"{self.name}.port_2.m_flow", - ), - role="flow", - value=self.port_2.m_flow - - self.port_mass_flow( - self.port_2.p, - props.p, - props.T, - port_name="port_2", - ), - ), - ) - + EQUATIONS = ({'id': '__MODEL__:port_1_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state', '__MODEL__.port_1.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:port_2_pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state', '__MODEL__.port_2.m_flow'], 'role': 'flow'}) class AmesimPnl0003(DynamicComponent): """AMESim PNL0003 C-R-C pneumatic pipe with two end compliances.""" - state_size = 4 - MODEL_TYPE = "amesim_pnl0003" - MODEL_VERSION = "0.4.0" - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - ) - PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + ( - ParameterDefinition( - "p1_0", - 100000.0, - label="端口 1 初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "T1_0", - 293.15, - label="端口 1 初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "p2_0", - 100000.0, - label="端口 2 初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "T2_0", - 293.15, - label="端口 2 初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("m1", "端口 1 侧质量", "mass", "kg", "state", 10), - ResultVariableDefinition("U1", "端口 1 侧内能", "internal_energy", "J", "state", 20), - ResultVariableDefinition("p1", "端口 1 侧压力", "pressure", "Pa", "thermodynamic", 30), - ResultVariableDefinition("T1", "端口 1 侧温度", "temperature", "K", "thermodynamic", 40), - ResultVariableDefinition("rho1", "端口 1 侧密度", "density", "kg/m³", "thermodynamic", 50), - ResultVariableDefinition("u1", "端口 1 侧比内能", "specific_internal_energy", "J/kg", "thermodynamic", 60), - ResultVariableDefinition("h1", "端口 1 侧比焓", "specific_enthalpy", "J/kg", "thermodynamic", 70), - ResultVariableDefinition("m2", "端口 2 侧质量", "mass", "kg", "state", 80), - ResultVariableDefinition("U2", "端口 2 侧内能", "internal_energy", "J", "state", 90), - ResultVariableDefinition("p2", "端口 2 侧压力", "pressure", "Pa", "thermodynamic", 100), - ResultVariableDefinition("T2", "端口 2 侧温度", "temperature", "K", "thermodynamic", 110), - ResultVariableDefinition("rho2", "端口 2 侧密度", "density", "kg/m³", "thermodynamic", 120), - ResultVariableDefinition("u2", "端口 2 侧比内能", "specific_internal_energy", "J/kg", "thermodynamic", 130), - ResultVariableDefinition("h2", "端口 2 侧比焓", "specific_enthalpy", "J/kg", "thermodynamic", 140), - ResultVariableDefinition("dmctr", "中心质量流量", "mass_flow", "kg/s", "derived", 150), - ResultVariableDefinition("re", "Reynolds 数", "dimensionless", "", "derived", 160), - ResultVariableDefinition("cm", "质量流量参数", "dimensionless", "", "derived", 170), - ResultVariableDefinition("v", "平均气体速度", "velocity", "m/s", "derived", 180), - ResultVariableDefinition("ff", "摩擦因子", "dimensionless", "", "derived", 190), - ) - DISPLAY = ComponentDisplaySpec( - label="PNL0003 C-R-C 动态管路", - library_id="amesim", - category_id="flow", - symbol="amesim_pnl0003", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=50, - parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS, - ) + MODEL_TYPE = 'amesim_pnl0003' + MODEL_VERSION = '0.4.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) + PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + (ParameterDefinition('p1_0', 100000.0, label='端口 1 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T1_0', 293.15, label='端口 1 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p2_0', 100000.0, label='端口 2 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T2_0', 293.15, label='端口 2 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) + RESULT_VARIABLES = (ResultVariableDefinition('m1', '端口 1 侧质量', 'mass', 'kg', 'state', 10), ResultVariableDefinition('U1', '端口 1 侧内能', 'internal_energy', 'J', 'state', 20), ResultVariableDefinition('p1', '端口 1 侧压力', 'pressure', 'Pa', 'thermodynamic', 30), ResultVariableDefinition('T1', '端口 1 侧温度', 'temperature', 'K', 'thermodynamic', 40), ResultVariableDefinition('rho1', '端口 1 侧密度', 'density', 'kg/m³', 'thermodynamic', 50), ResultVariableDefinition('u1', '端口 1 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 60), ResultVariableDefinition('h1', '端口 1 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 70), ResultVariableDefinition('m2', '端口 2 侧质量', 'mass', 'kg', 'state', 80), ResultVariableDefinition('U2', '端口 2 侧内能', 'internal_energy', 'J', 'state', 90), ResultVariableDefinition('p2', '端口 2 侧压力', 'pressure', 'Pa', 'thermodynamic', 100), ResultVariableDefinition('T2', '端口 2 侧温度', 'temperature', 'K', 'thermodynamic', 110), ResultVariableDefinition('rho2', '端口 2 侧密度', 'density', 'kg/m³', 'thermodynamic', 120), ResultVariableDefinition('u2', '端口 2 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 130), ResultVariableDefinition('h2', '端口 2 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 140), ResultVariableDefinition('dmctr', '中心质量流量', 'mass_flow', 'kg/s', 'derived', 150), ResultVariableDefinition('re', 'Reynolds 数', 'dimensionless', '', 'derived', 160), ResultVariableDefinition('cm', '质量流量参数', 'dimensionless', '', 'derived', 170), ResultVariableDefinition('v', '平均气体速度', 'velocity', 'm/s', 'derived', 180), ResultVariableDefinition('ff', '摩擦因子', 'dimensionless', '', 'derived', 190)) + DISPLAY = ComponentDisplaySpec(label='PNL0003 C-R-C 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0003', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=50, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS) - def __init__( - self, - name: str, - medium: GasMedium, - *, - diam: float = 0.01, - le: float = 1.0, - rr: float = 1.0e-5, - k: float = 1.35, - kth: float = 0.0, - extemp: float = 293.15, - gi: float = 1.0, - mode: float = 2.0, - p1_0: float = 100000.0, - T1_0: float = 293.15, - p2_0: float = 100000.0, - T2_0: float = 293.15, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, diam: float=0.01, le: float=1.0, rr: float=1e-05, k: float=1.35, kth: float=0.0, extemp: float=293.15, gi: float=1.0, mode: float=2.0, p1_0: float=100000.0, T1_0: float=293.15, p2_0: float=100000.0, T2_0: float=293.15) -> None: super().__init__(name=name) - self.set_parameter_values( - { - "diam": diam, - "le": le, - "rr": rr, - "k": k, - "kth": kth, - "extemp": extemp, - "gi": gi, - "mode": mode, - "p1_0": p1_0, - "T1_0": T1_0, - "p2_0": p2_0, - "T2_0": T2_0, - } - ) + self.set_parameter_values({'diam': diam, 'le': le, 'rr': rr, 'k': k, 'kth': kth, 'extemp': extemp, 'gi': gi, 'mode': mode, 'p1_0': p1_0, 'T1_0': T1_0, 'p2_0': p2_0, 'T2_0': T2_0}) self.medium = medium self.diam = float(diam) self.le = float(le) @@ -1689,248 +133,15 @@ class AmesimPnl0003(DynamicComponent): self.kth = float(kth) self.extemp = float(extemp) self.gi = normalize_amesim_gas_index(gi) - self.mode = AmesimPnl0001._integer_parameter("mode", mode) + self.mode = AmesimPnl0001._integer_parameter('mode', mode) self.area = pi * self.diam * self.diam / 4.0 self.volume = self.area * self.le self.compliance_volume = self.volume / 2.0 self.exchange_area = pi * self.diam * self.le - self.state_1 = self._initial_state(float(p1_0), float(T1_0)) - self.state_2 = self._initial_state(float(p2_0), float(T2_0)) - h1 = medium.specific_enthalpy_at_pressure(float(p1_0), float(T1_0)) - h2 = medium.specific_enthalpy_at_pressure(float(p2_0), float(T2_0)) - self.port_1 = self.register_declared_port("port_1") - self.port_1.p = float(p1_0) - self.port_1.h_outflow = h1 - self.port_2 = self.register_declared_port("port_2") - self.port_2.p = float(p2_0) - self.port_2.h_outflow = h2 + self.port_1 = self.register_declared_port('port_1') + self.port_2 = self.register_declared_port('port_2') @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> "AmesimPnl0003": + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnl0003': return cls(name=name, medium=medium, **dict(parameters)) - - def _initial_state(self, pressure: float, temperature: float) -> VolumeState: - mass = self.medium.density(pressure, temperature) * self.compliance_volume - return VolumeState( - m=mass, - U=mass * self.medium.specific_internal_energy_at_pressure( - pressure, - temperature, - ), - ) - - def get_state_vector(self) -> list[float]: - return [*self.state_1.as_vector(), *self.state_2.as_vector()] - - def set_state_vector(self, values: list[float]) -> None: - if len(values) != 4: - raise ValueError("PNL0003 state vector requires four values") - self.state_1 = VolumeState.from_vector(values[:2]) - self.state_2 = VolumeState.from_vector(values[2:]) - - def _properties(self, state: VolumeState) -> ThermodynamicProperties: - return self.medium.properties_from_mU(state.m, state.U, self.compliance_volume) - - def properties_1(self) -> ThermodynamicProperties: - props = self._properties(self.state_1) - self.port_1.p = props.p - self.port_1.h_outflow = props.h - return props - - def properties_2(self) -> ThermodynamicProperties: - props = self._properties(self.state_2) - self.port_2.p = props.p - self.port_2.h_outflow = props.h - return props - - def refresh_thermodynamic_ports(self) -> tuple[ThermodynamicProperties, ThermodynamicProperties]: - return self.properties_1(), self.properties_2() - - def _dynamic_viscosity(self, temperature_k: float) -> float: - return self.medium.dynamic_viscosity(temperature_k) - - def reynolds_number(self, mass_flow: float, temperature: float) -> float: - viscosity = self._dynamic_viscosity(temperature) - return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) - - def reported_reynolds_number( - self, - mass_flow: float, - temperature: float, - ) -> float: - """Return AMESim's derived re without changing pipe dynamics.""" - - viscosity = self.medium.diagnostic_dynamic_viscosity(temperature) - return 4.0 * abs(mass_flow) / (pi * self.diam * viscosity) - - def friction_factor(self, reynolds_number: float) -> float: - return AmesimPnl00r.friction_factor(self, reynolds_number) - - def darcy_pressure_drop( - self, - mass_flow: float, - *, - density: float, - temperature: float, - ) -> float: - if mass_flow == 0.0: - return 0.0 - reynolds = self.reynolds_number(mass_flow, temperature) - friction = self.friction_factor(reynolds) - velocity = mass_flow / (density * self.area) - magnitude = friction * (self.le / self.diam) * density * velocity * velocity / 2.0 - return magnitude if mass_flow > 0.0 else -magnitude - - @lru_cache(maxsize=32768) - def _mass_flow_for_pressure_drop( - self, - pressure_drop: float, - *, - density: float, - temperature: float, - ) -> float: - if pressure_drop <= 0.0: - return 0.0 - upper = 1.0e-9 - while self.darcy_pressure_drop(upper, density=density, temperature=temperature) < pressure_drop: - upper *= 10.0 - if upper > 1.0e3: - return 1.0e3 - lower = 0.0 - for _ in range(48): - middle = 0.5 * (lower + upper) - if self.darcy_pressure_drop(middle, density=density, temperature=temperature) < pressure_drop: - lower = middle - else: - upper = middle - return 0.5 * (lower + upper) - - def resistance_mass_flow(self) -> float: - port_1 = self._properties(self.state_1) - port_2 = self._properties(self.state_2) - pressure_difference = port_1.p - port_2.p - if isclose(port_1.p, port_2.p, rel_tol=0.0, abs_tol=1.0e-8): - return 0.0 - upstream = port_1 if pressure_difference > 0.0 else port_2 - magnitude = self._mass_flow_for_pressure_drop( - abs(pressure_difference), - density=upstream.rho, - temperature=upstream.T, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def _heat_flow_each(self, temperature_1: float, temperature_2: float) -> float: - if self.mode == 1: - return 0.0 - return self.kth * self.exchange_area * (self.extemp - 0.5 * (temperature_1 + temperature_2)) / 2.0 - - def component_result_values(self) -> Mapping[str, float]: - port_1 = self.properties_1() - port_2 = self.properties_2() - center_flow = self.resistance_mass_flow() - upstream = port_1 if center_flow >= 0.0 else port_2 - reynolds = self.reynolds_number(center_flow, upstream.T) - reported_reynolds = self.reported_reynolds_number(center_flow, upstream.T) - friction = self.friction_factor(reynolds) - return { - "m1": self.state_1.m, - "U1": self.state_1.U, - "p1": port_1.p, - "T1": port_1.T, - "rho1": port_1.rho, - "u1": port_1.u, - "h1": port_1.h, - "m2": self.state_2.m, - "U2": self.state_2.U, - "p2": port_2.p, - "T2": port_2.T, - "rho2": port_2.rho, - "u2": port_2.u, - "h2": port_2.h, - "dmctr": center_flow, - "re": reported_reynolds, - "cm": _bare_pipe_mass_flow_parameter( - center_flow, - area=self.area, - diameter=self.diam, - upstream_pressure=max(port_1.p, port_2.p, 1.0), - upstream_temperature=upstream.T, - resistance_length=self.le, - friction_factor=friction, - ), - "v": center_flow / (max(upstream.rho, 1.0e-12) * self.area), - "ff": _reported_friction_factor(friction), - } - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - port_1 = self._properties(self.state_1) - port_2 = self._properties(self.state_2) - return ( - self.port_1.p - port_1.p, - self.port_2.p - port_2.p, - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - port_1 = self._properties(self.state_1) - port_2 = self._properties(self.state_2) - return ( - EquationResidual( - id=f"{self.name}:port_1_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_1.p", f"{self.name}.state"), - role="effort", - value=self.port_1.p - port_1.p, - ), - EquationResidual( - id=f"{self.name}:port_2_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_2.p", f"{self.name}.state"), - role="effort", - value=self.port_2.p - port_2.p, - ), - ) - - def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: - port_1 = self.properties_1() - port_2 = self.properties_2() - center_flow = self.resistance_mass_flow() - heat_flow_each = self._heat_flow_each(port_1.T, port_2.T) - port_1_external_h = self.connection_inlet_enthalpy( - port_m_flow=self.port_1.m_flow, - connected_h=connected_h["port_1"], - internal_h=port_1.h, - ) - port_2_external_h = self.connection_inlet_enthalpy( - port_m_flow=self.port_2.m_flow, - connected_h=connected_h["port_2"], - internal_h=port_2.h, - ) - port_1_center_h = self.connection_inlet_enthalpy( - port_m_flow=-center_flow, - connected_h=port_2.h, - internal_h=port_1.h, - ) - port_2_center_h = self.connection_inlet_enthalpy( - port_m_flow=center_flow, - connected_h=port_1.h, - internal_h=port_2.h, - ) - d1 = VolumeState( - m=self.port_1.m_flow - center_flow, - U=self.port_1.m_flow * port_1_external_h - center_flow * port_1_center_h + heat_flow_each, - ) - d2 = VolumeState( - m=self.port_2.m_flow + center_flow, - U=self.port_2.m_flow * port_2_external_h + center_flow * port_2_center_h + heat_flow_each, - ) - return [*d1.as_vector(), *d2.as_vector()] + EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}) diff --git a/app/simulation/components/amesim/junctions/nodes.py b/app/simulation/components/amesim/junctions/nodes.py index 00f1d2a..ab378e5 100644 --- a/app/simulation/components/amesim/junctions/nodes.py +++ b/app/simulation/components/amesim/junctions/nodes.py @@ -1,31 +1,10 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping - from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual from app.simulation.core.medium import IdealGasMedium -from app.simulation.core.ports import PortDefinition, PortState - - -_REFERENCE_OUTFLOW_REGULARIZATION_RATIO = 0.05 - - -def _regularized_inverse_outflow(flow: float, transition_flow: float) -> float: - """Return a C1 inverse that tends to zero as a negative flow vanishes.""" - - if flow >= 0.0: - return 0.0 - transition_flow = max(float(transition_flow), 1.0e-12) - if -flow >= transition_flow: - return 1.0 / flow - return ( - flow - * (2.0 * transition_flow * transition_flow - flow * flow) - / transition_flow**4 - ) - +from app.simulation.core.ports import PortDefinition class _AmesimPneumaticNode(AlgebraicComponent): """Shared implementation for AMESim pneumatic junction submodels. @@ -35,9 +14,7 @@ class _AmesimPneumaticNode(AlgebraicComponent): an outlet, its enthalpy is the residual that closes the junction energy balance, matching the AMESim dh2 causality. """ - - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - REFERENCE_PORT = "port_2" + REFERENCE_PORT = 'port_2' def __init__(self, name: str) -> None: super().__init__(name=name) @@ -46,194 +23,30 @@ class _AmesimPneumaticNode(AlgebraicComponent): for definition in self.PORTS: setattr(self, definition.name, self.register_declared_port(definition.name)) - def pressure_flow_equation_values(self) -> tuple[float, ...]: - reference = self.get_port(self.REFERENCE_PORT) - return tuple( - self.get_port(definition.name).p - reference.p - for definition in self.PORTS - if definition.name != self.REFERENCE_PORT - ) + ( - sum( - self.get_port(definition.name).m_flow - for definition in self.PORTS - ), - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - reference = self.get_port(self.REFERENCE_PORT) - residuals: list[EquationResidual] = [] - for definition in self.PORTS: - if definition.name == self.REFERENCE_PORT: - continue - port = self.get_port(definition.name) - residuals.append( - EquationResidual( - id=f"{self.name}:{definition.name}_pressure_reference", - owner="component", - owner_id=self.name, - relation="equal", - variables=( - f"{self.name}.{definition.name}.p", - f"{self.name}.{self.REFERENCE_PORT}.p", - ), - role="effort", - value=port.p - reference.p, - ) - ) - residuals.append( - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=tuple( - f"{self.name}.{definition.name}.m_flow" - for definition in self.PORTS - ), - role="flow", - value=sum(self.get_port(definition.name).m_flow for definition in self.PORTS), - ) - ) - return tuple(residuals) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self.temperature_reference_h = connected_h.get( - self.REFERENCE_PORT, - sum(connected_h.values()) / len(connected_h) if connected_h else 0.0, - ) - incoming = [ - (port.m_flow, connected_h[name]) - for name, port in self.ports.items() - if port.m_flow > 1e-12 - ] - total_flow = sum(m_flow for m_flow, _ in incoming) - if total_flow > 1e-12: - mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow - else: - mixed_h = self.temperature_reference_h - - reference_port = self.get_port(self.REFERENCE_PORT) - for name, port in self.ports.items(): - port.h_outflow = ( - mixed_h - if name == self.REFERENCE_PORT - else self.temperature_reference_h - ) - - if reference_port.m_flow < 0.0: - energy_without_reference = sum( - port.m_flow - * ( - connected_h[name] - if port.m_flow > 1e-12 - else self.temperature_reference_h - ) - for name, port in self.ports.items() - if name != self.REFERENCE_PORT - ) - non_reference_flow_scale = sum( - abs(port.m_flow) - for name, port in self.ports.items() - if name != self.REFERENCE_PORT - ) - transition_flow = ( - _REFERENCE_OUTFLOW_REGULARIZATION_RATIO - * non_reference_flow_scale - ) - # Port 2 carries AMESim's residual-energy causality. Exact - # division is singular when its outflow reverses through zero, so - # use a C1 band that matches the exact balance at its boundary and - # tends to the mixed enthalpy at zero flow. - inverse_flow = _regularized_inverse_outflow( - reference_port.m_flow, - transition_flow, - ) - energy_residual_at_mixed_h = ( - energy_without_reference - + reference_port.m_flow * mixed_h - ) - reference_port.h_outflow = ( - mixed_h - energy_residual_at_mixed_h * inverse_flow - ) - - class AmesimPn3Node2(_AmesimPneumaticNode): """AMESim PN3NODE2 pneumatic three-port junction.""" - - MODEL_TYPE = "amesim_pn3node2" - MODEL_VERSION = "0.3.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), - ) + MODEL_TYPE = 'amesim_pn3node2' + MODEL_VERSION = '0.3.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional')) PARAMETERS = () RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="PN3NODE2 三端气动节点", - library_id="amesim", - category_id="junctions", - symbol="amesim_pn3node2", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - PortDisplaySpec("port_3", "right", order=30), - ), - order=10, - ) + DISPLAY = ComponentDisplaySpec(label='PN3NODE2 三端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_pn3node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30)), order=10) @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> AmesimPn3Node2: + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimPn3Node2: return cls(name=name) - + EQUATIONS = ({'id': '__MODEL__:port_1_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow'], 'role': 'flow'}) class AmesimP4Node2(_AmesimPneumaticNode): """AMESim P4NODE2 pneumatic four-port junction.""" - - MODEL_TYPE = "amesim_p4node2" - MODEL_VERSION = "0.3.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_4", nominal_role="bidirectional"), - ) + MODEL_TYPE = 'amesim_p4node2' + MODEL_VERSION = '0.3.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional')) PARAMETERS = () RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="P4NODE2 四端气动节点", - library_id="amesim", - category_id="junctions", - symbol="amesim_p4node2", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - PortDisplaySpec("port_3", "right", order=30), - PortDisplaySpec("port_4", "right", order=40), - ), - order=20, - ) + DISPLAY = ComponentDisplaySpec(label='P4NODE2 四端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_p4node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20) @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> AmesimP4Node2: + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> AmesimP4Node2: return cls(name=name) + EQUATIONS = ({'id': '__MODEL__:port_1_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_1.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:port_4_pressure_reference', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_4.p', '__MODEL__.port_2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_1.m_flow', '__MODEL__.port_2.m_flow', '__MODEL__.port_3.m_flow', '__MODEL__.port_4.m_flow'], 'role': 'flow'}) diff --git a/app/simulation/components/amesim/mechanical/pistons.py b/app/simulation/components/amesim/mechanical/pistons.py index e8da319..86ff826 100644 --- a/app/simulation/components/amesim/mechanical/pistons.py +++ b/app/simulation/components/amesim/mechanical/pistons.py @@ -1,36 +1,15 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from math import isfinite, pi - -from app.simulation.components.amesim.gases import ( - AMESIM_GAS_INDEX_PARAMETER, - normalize_amesim_gas_index, -) +from collections.abc import Mapping +from math import pi +from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition from app.simulation.core.medium import GasMedium from app.simulation.core.ports import PortDefinition - - AMESIM_REFERENCE_PRESSURE_PA = 101300.0 - -@dataclass(frozen=True) -class Pnrp17Linearization: - volume: float - volume_flow: float - pressure_force: float - volume_tangent: tuple[float, ...] - volume_flow_tangent: tuple[float, ...] - pressure_force_tangent: tuple[float, ...] - valid: bool = True - reason: str | None = None - - class AmesimPnrp17(AlgebraicComponent): """AMESim PNRP17 pneumatic piston with two mechanical faces. @@ -38,268 +17,32 @@ class AmesimPnrp17(AlgebraicComponent): cylinder-side motion. The pneumatic port contributes its swept volume and volume rate to the connected variable-volume chamber. """ + MODEL_TYPE = 'amesim_pnrp17' + MODEL_VERSION = '0.1.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.mechanical_translational('port_2'), PortDefinition.mechanical_translational('port_3'), PortDefinition.mechanical_translational('port_4'), PortDefinition.mechanical_translational('port_5')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('dp', 0.2, label='活塞直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='活塞外径;与活塞杆直径共同确定有效受压面积。'), ParameterDefinition('dr', 0.001, label='活塞杆直径', quantity='length', unit='m', minimum=0.0, description='穿过气室一侧的活塞杆直径,必须不大于活塞直径。'), ParameterDefinition('x0', 0.0, label='初始腔长', quantity='length', unit='m', description='机械端位移均为零时的气动腔长度。')) + RESULT_VARIABLES = (ResultVariableDefinition('volume', '扫掠容积', 'volume', 'm3', 'derived', 10), ResultVariableDefinition('volume_flow', '扫掠容积变化率', 'volume_flow', 'm3/s', 'derived', 20), ResultVariableDefinition('length', '气动腔长度', 'length', 'm', 'derived', 30), ResultVariableDefinition('pressure_force', '气压力', 'force', 'N', 'derived', 40)) + DISPLAY = ComponentDisplaySpec(label='PNRP17 气动活塞', library_id='amesim', category_id='mechanical', symbol='amesim_pnrp17', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_3', 'left', order=20), PortDisplaySpec('port_2', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40), PortDisplaySpec('port_5', 'right', order=50)), order=60) - MODEL_TYPE = "amesim_pnrp17" - MODEL_VERSION = "0.1.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.mechanical_translational("port_2"), - PortDefinition.mechanical_translational("port_3"), - PortDefinition.mechanical_translational("port_4"), - PortDefinition.mechanical_translational("port_5"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "dp", - 0.2, - label="活塞直径", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - description="活塞外径;与活塞杆直径共同确定有效受压面积。", - ), - ParameterDefinition( - "dr", - 0.001, - label="活塞杆直径", - quantity="length", - unit="m", - minimum=0.0, - description="穿过气室一侧的活塞杆直径,必须不大于活塞直径。", - ), - ParameterDefinition( - "x0", - 0.0, - label="初始腔长", - quantity="length", - unit="m", - description="机械端位移均为零时的气动腔长度。", - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("volume", "扫掠容积", "volume", "m3", "derived", 10), - ResultVariableDefinition( - "volume_flow", - "扫掠容积变化率", - "volume_flow", - "m3/s", - "derived", - 20, - ), - ResultVariableDefinition("length", "气动腔长度", "length", "m", "derived", 30), - ResultVariableDefinition( - "pressure_force", - "气压力", - "force", - "N", - "derived", - 40, - ), - ) - DISPLAY = ComponentDisplaySpec( - label="PNRP17 气动活塞", - library_id="amesim", - category_id="mechanical", - symbol="amesim_pnrp17", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_3", "left", order=20), - PortDisplaySpec("port_2", "left", order=30), - PortDisplaySpec("port_4", "right", order=40), - PortDisplaySpec("port_5", "right", order=50), - ), - order=60, - ) - - def __init__( - self, - name: str, - medium: GasMedium, - *, - gi: float = 0.0, - dp: float = 0.2, - dr: float = 0.001, - x0: float = 0.0, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, gi: float=0.0, dp: float=0.2, dr: float=0.001, x0: float=0.0) -> None: super().__init__(name=name) - self.set_parameter_values({"gi": gi, "dp": dp, "dr": dr, "x0": x0}) + self.set_parameter_values({'gi': gi, 'dp': dp, 'dr': dr, 'x0': x0}) self.medium = medium self.gi = normalize_amesim_gas_index(gi) self.dp = float(dp) self.dr = float(dr) self.x0 = float(x0) if self.dr > self.dp: - raise ValueError("PNRP17 rod diameter dr must not exceed piston diameter dp.") + raise ValueError('PNRP17 rod diameter dr must not exceed piston diameter dp.') for definition in self.PORTS: port = self.register_declared_port(definition.name) setattr(self, definition.name, port) - self.port_1.h_outflow = medium.specific_enthalpy(medium.T_ref) @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> "AmesimPnrp17": + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnrp17': return cls(name=name, medium=medium, **dict(parameters)) @property def effective_area(self) -> float: return pi * (self.dp * self.dp - self.dr * self.dr) / 4.0 - - @property - def chamber_length(self) -> float: - return self.x0 + self.port_5.x - self.port_4.x - - @property - def chamber_volume(self) -> float: - return self.effective_area * self.chamber_length - - @property - def chamber_volume_flow(self) -> float: - return self.effective_area * (self.port_5.v - self.port_4.v) - - @property - def pressure_force(self) -> float: - return (self.port_1.p - AMESIM_REFERENCE_PRESSURE_PA) * self.effective_area - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - values = [self.port_1.m_flow] - effort_pairs = (("port_2", "port_5"), ("port_3", "port_4")) - for first_name, second_name in effort_pairs: - first = self.get_port(first_name) - second = self.get_port(second_name) - values.extend((first.x - second.x, first.v - second.v)) - force = self.pressure_force - values.extend( - ( - self.port_2.f + self.port_5.f + force, - self.port_3.f + self.port_4.f - force, - ) - ) - return tuple(values) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - effort_pairs = (("port_2", "port_5"), ("port_3", "port_4")) - residuals: list[EquationResidual] = [ - EquationResidual( - id=f"{self.name}:pneumatic_zero_mass_flow", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_1.m_flow",), - role="flow", - value=self.port_1.m_flow, - ) - ] - for first_name, second_name in effort_pairs: - first = self.get_port(first_name) - second = self.get_port(second_name) - for variable in ("x", "v"): - residuals.append( - EquationResidual( - id=f"{self.name}:{first_name}_{second_name}_{variable}_equal", - owner="component", - owner_id=self.name, - relation="equal", - variables=( - f"{self.name}.{first_name}.{variable}", - f"{self.name}.{second_name}.{variable}", - ), - role="effort", - value=getattr(first, variable) - getattr(second, variable), - ) - ) - force = self.pressure_force - residuals.extend( - ( - EquationResidual( - id=f"{self.name}:piston_side_force_balance", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_2.f", f"{self.name}.port_5.f", f"{self.name}.port_1.p"), - role="flow", - value=self.port_2.f + self.port_5.f + force, - ), - EquationResidual( - id=f"{self.name}:cylinder_side_force_balance", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_3.f", f"{self.name}.port_4.f", f"{self.name}.port_1.p"), - role="flow", - value=self.port_3.f + self.port_4.f - force, - ), - ) - ) - return tuple(residuals) - - def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]: - return {"port_1": (self.chamber_volume, self.chamber_volume_flow)} - - def linearize_geometry_and_force( - self, - port_4_x_tangent: Sequence[float], - port_5_x_tangent: Sequence[float], - port_4_v_tangent: Sequence[float], - port_5_v_tangent: Sequence[float], - port_1_pressure_tangent: Sequence[float], - ) -> Pnrp17Linearization: - """Return exact piston geometry and pressure-force tangents.""" - - vectors = tuple( - tuple(float(value) for value in values) - for values in ( - port_4_x_tangent, - port_5_x_tangent, - port_4_v_tangent, - port_5_v_tangent, - port_1_pressure_tangent, - ) - ) - widths = {len(values) for values in vectors} - if len(widths) != 1: - raise ValueError("PNRP17 tangent vectors must have equal lengths.") - valid = all(isfinite(value) for values in vectors for value in values) - area = self.effective_area - volume_tangent = tuple( - area * (right - left) - for left, right in zip(vectors[0], vectors[1], strict=True) - ) - volume_flow_tangent = tuple( - area * (right - left) - for left, right in zip(vectors[2], vectors[3], strict=True) - ) - pressure_force_tangent = tuple( - area * value for value in vectors[4] - ) - return Pnrp17Linearization( - volume=self.chamber_volume, - volume_flow=self.chamber_volume_flow, - pressure_force=self.pressure_force, - volume_tangent=volume_tangent, - volume_flow_tangent=volume_flow_tangent, - pressure_force_tangent=pressure_force_tangent, - valid=valid, - reason=None if valid else "non_finite_tangent_input", - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self.port_1.h_outflow = connected_h.get( - "port_1", - self.medium.specific_enthalpy(self.medium.T_ref), - ) - - def component_result_values(self) -> Mapping[str, float]: - return { - "volume": self.chamber_volume, - "volume_flow": self.chamber_volume_flow, - "length": self.chamber_length, - "pressure_force": self.pressure_force, - } + EQUATIONS = ({'id': '__MODEL__:pneumatic_zero_mass_flow', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:port_2_port_5_x_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_2.x', '__MODEL__.port_5.x'], 'role': 'effort'}, {'id': '__MODEL__:port_2_port_5_v_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_2.v', '__MODEL__.port_5.v'], 'role': 'effort'}, {'id': '__MODEL__:port_3_port_4_x_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.x', '__MODEL__.port_4.x'], 'role': 'effort'}, {'id': '__MODEL__:port_3_port_4_v_equal', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_3.v', '__MODEL__.port_4.v'], 'role': 'effort'}, {'id': '__MODEL__:piston_side_force_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.port_5.f', '__MODEL__.port_1.p'], 'role': 'flow'}, {'id': '__MODEL__:cylinder_side_force_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_3.f', '__MODEL__.port_4.f', '__MODEL__.port_1.p'], 'role': 'flow'}) diff --git a/app/simulation/components/amesim/mechanical/translational.py b/app/simulation/components/amesim/mechanical/translational.py index 10265a4..d95d017 100644 --- a/app/simulation/components/amesim/mechanical/translational.py +++ b/app/simulation/components/amesim/mechanical/translational.py @@ -1,571 +1,69 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from math import expm1, isfinite - +from collections.abc import Mapping from app.simulation.core.base import AlgebraicComponent, DynamicComponent -from app.simulation.core.catalog import ( - ComponentDisplaySpec, - ParameterGroupDisplaySpec, - PortDisplaySpec, -) -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterCondition, - ParameterDefinition, - ParameterOption, - ResultVariableDefinition, -) +from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec +from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition - - -@dataclass(frozen=True) -class Mecmas21DerivativeLinearization: - derivative: tuple[float, float] - tangents: tuple[tuple[float, ...], tuple[float, ...]] - mode: str - valid: bool = True - reason: str | None = None - - -@dataclass(frozen=True) -class LstpContactForceLinearization: - force: float - force_tangent: tuple[float, ...] - mode: str - valid: bool = True - reason: str | None = None - - -_MECMAS21_FRICTION_ENABLED = ParameterCondition("useFriction", (2.0,)) -_MECMAS21_NON_RESTITUTION = ParameterCondition("stoptype", (1.0, 2.0, 4.0)) -_MECMAS21_LIMITS_ENABLED = ParameterCondition("stoptype", (1.0, 2.0, 3.0)) -_MECMAS21_ELASTIC_STOP = ParameterCondition("stoptype", (2.0,)) -_MECMAS21_RESTITUTION_STOP = ParameterCondition("stoptype", (3.0,)) -_MECMAS21_ADVANCED_FRICTION = ParameterCondition("frictionType", (2.0,)) -_MECMAS21_STRIBECK_ENABLED = ParameterCondition("strib", (2.0,)) -_LSTP00A_NUMERICAL_STIFFNESS = ParameterCondition("stiffmode", (1.0,)) -_LSTP00A_GEOMETRICAL_STIFFNESS = ParameterCondition("stiffmode", (2.0,)) - +_MECMAS21_FRICTION_ENABLED = ParameterCondition('useFriction', (2.0,)) +_MECMAS21_NON_RESTITUTION = ParameterCondition('stoptype', (1.0, 2.0, 4.0)) +_MECMAS21_LIMITS_ENABLED = ParameterCondition('stoptype', (1.0, 2.0, 3.0)) +_MECMAS21_ELASTIC_STOP = ParameterCondition('stoptype', (2.0,)) +_MECMAS21_RESTITUTION_STOP = ParameterCondition('stoptype', (3.0,)) +_MECMAS21_ADVANCED_FRICTION = ParameterCondition('frictionType', (2.0,)) +_MECMAS21_STRIBECK_ENABLED = ParameterCondition('strib', (2.0,)) +_LSTP00A_NUMERICAL_STIFFNESS = ParameterCondition('stiffmode', (1.0,)) +_LSTP00A_GEOMETRICAL_STIFFNESS = ParameterCondition('stiffmode', (2.0,)) class AmesimF000(AlgebraicComponent): """AMESim F000 zero force source.""" - - MODEL_TYPE = "amesim_f000" - MODEL_VERSION = "0.1.0" - PORTS = (PortDefinition.mechanical_translational("port_1"),) + MODEL_TYPE = 'amesim_f000' + MODEL_VERSION = '0.1.0' + PORTS = (PortDefinition.mechanical_translational('port_1'),) PARAMETERS = () RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="F000 零力源", - library_id="amesim", - category_id="mechanical", - symbol="amesim_f000", - ports=(PortDisplaySpec("port_1", "right", order=10),), - order=10, - ) + DISPLAY = ComponentDisplaySpec(label='F000 零力源', library_id='amesim', category_id='mechanical', symbol='amesim_f000', ports=(PortDisplaySpec('port_1', 'right', order=10),), order=10) def __init__(self, name: str) -> None: super().__init__(name=name) self.set_parameter_values({}) - self.port_1 = self.register_declared_port("port_1") + self.port_1 = self.register_declared_port('port_1') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> "AmesimF000": + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimF000': return cls(name=name) - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return (self.port_1.f,) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:zero_force", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_1.f",), - role="flow", - value=self.port_1.f, - ), - ) - + EQUATIONS = ({'id': '__MODEL__:zero_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.f'], 'role': 'flow'},) class AmesimForc(AlgebraicComponent): """AMESim FORC signal-to-force converter.""" + MODEL_TYPE = 'amesim_forc' + MODEL_VERSION = '0.2.0' + PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.mechanical_translational('port_2')) + PARAMETERS = (ParameterDefinition('direction', 1.0, label='力方向', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '正向'), ParameterOption(-1.0, '反向')), description='显式控制输入信号相对于机械端口正方向的力符号;图标旋转和镜像不会改变该参数。'),) + RESULT_VARIABLES = (ResultVariableDefinition('force', '输出力', 'force', 'N', 'signal', 10),) + DISPLAY = ComponentDisplaySpec(label='FORC 信号转力', library_id='amesim', category_id='mechanical', symbol='amesim_forc', ports=(PortDisplaySpec('res', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20) - MODEL_TYPE = "amesim_forc" - MODEL_VERSION = "0.2.0" - PORTS = ( - PortDefinition.signal("res", nominal_role="input"), - PortDefinition.mechanical_translational("port_2"), - ) - PARAMETERS = ( - ParameterDefinition( - "direction", - 1.0, - label="力方向", - quantity="dimensionless", - unit="", - editor="choice", - options=( - ParameterOption(1.0, "正向"), - ParameterOption(-1.0, "反向"), - ), - description=( - "显式控制输入信号相对于机械端口正方向的力符号;" - "图标旋转和镜像不会改变该参数。" - ), - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10), - ) - DISPLAY = ComponentDisplaySpec( - label="FORC 信号转力", - library_id="amesim", - category_id="mechanical", - symbol="amesim_forc", - ports=( - PortDisplaySpec("res", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=20, - ) - - def __init__(self, name: str, *, direction: float = 1.0) -> None: + def __init__(self, name: str, *, direction: float=1.0) -> None: super().__init__(name=name) - self.set_parameter_values({"direction": direction}) + self.set_parameter_values({'direction': direction}) self.direction = float(direction) - self.res = self.register_declared_port("res") - self.port_2 = self.register_declared_port("port_2") + self.res = self.register_declared_port('res') + self.port_2 = self.register_declared_port('port_2') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> "AmesimForc": - return cls(name=name, direction=parameters["direction"]) - - @property - def output_force(self) -> float: - return self.direction * float(self.res.signal) - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return ( - self.port_2.f + self.output_force, - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:signal_force", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"), - role="flow", - value=self.port_2.f + self.output_force, - ), - ) - - def component_result_values(self) -> Mapping[str, float]: - return {"force": self.output_force} - + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimForc': + return cls(name=name, direction=parameters['direction']) + EQUATIONS = ({'id': '__MODEL__:signal_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.res.signal'], 'role': 'flow'},) class AmesimMecmas21(DynamicComponent): """AMESim MECMAS21 first public one-dimensional translational mass.""" - - MODEL_TYPE = "amesim_mecmas21" - MODEL_VERSION = "0.2.0" - PORTS = ( - PortDefinition.mechanical_translational("port_1"), - PortDefinition.mechanical_translational("port_2"), - ) - PARAMETERS = ( - ParameterDefinition( - "mass", - 1.0, - label="质量", - quantity="mass", - unit="kg", - minimum=0.0, - minimum_exclusive=True, - description="平动质量,必须大于零。", - ), - ParameterDefinition( - "fstick", - 0.0, - label="静摩擦力", - quantity="force", - unit="N", - minimum=0.0, - description=( - "AMESim 静摩擦阈值。当前公开求解器尚未实现静摩擦保持与脱离逻辑," - "因此该参数暂不参与力计算。" - ), - visible_when=( - _MECMAS21_NON_RESTITUTION, - _MECMAS21_FRICTION_ENABLED, - ), - ), - ParameterDefinition( - "fcoul", - 0.0, - label="库仑摩擦力", - quantity="force", - unit="N", - minimum=0.0, - description="滑动时的库仑摩擦力绝对值,方向与速度相反。", - visible_when=( - _MECMAS21_NON_RESTITUTION, - _MECMAS21_FRICTION_ENABLED, - ), - ), - ParameterDefinition( - "rvisc", - 0.0, - label="黏性摩擦系数", - quantity="translational_damping", - unit="N/(m/s)", - minimum=0.0, - description="与速度成正比的黏性摩擦系数。", - visible_when=(_MECMAS21_FRICTION_ENABLED,), - ), - ParameterDefinition( - "wind", - 0.0, - label="风阻系数", - quantity="windage", - unit="N/(m/s)^2", - minimum=0.0, - description="与速度平方成正比、方向与速度相反的风阻系数。", - visible_when=(_MECMAS21_FRICTION_ENABLED,), - ), - ParameterDefinition( - "dvel", - 1.0e-6, - label="粘滞速度阈值", - quantity="velocity", - unit="m/s", - minimum=0.0, - description=( - "高级摩擦模型的低速切换阈值;当前公开求解器尚未实现对应的" - "高级静摩擦公式。" - ), - visible_when=( - _MECMAS21_NON_RESTITUTION, - _MECMAS21_FRICTION_ENABLED, - _MECMAS21_ADVANCED_FRICTION, - ), - ), - ParameterDefinition( - "restdvel", - 1.0e-6, - label="恢复速度阈值", - quantity="velocity", - unit="m/s", - minimum=0.0, - description="恢复碰撞低于该入射速度时按无回弹处理。", - visible_when=(_MECMAS21_RESTITUTION_STOP,), - ), - ParameterDefinition( - "restcoeff", - 0.65, - label="恢复系数", - quantity="dimensionless", - unit="", - minimum=0.0, - maximum=1.0, - description="恢复碰撞后的速度与碰撞前速度绝对值之比。", - visible_when=(_MECMAS21_RESTITUTION_STOP,), - ), - ParameterDefinition( - "astrib", - 1.0e-3, - label="Stribeck 常数", - quantity="velocity", - unit="m/s", - minimum=0.0, - description=( - "高级 Stribeck 摩擦模型的速度常数;当前公开求解器尚未实现" - "对应的 Stribeck 公式。" - ), - visible_when=( - _MECMAS21_NON_RESTITUTION, - _MECMAS21_FRICTION_ENABLED, - _MECMAS21_ADVANCED_FRICTION, - _MECMAS21_STRIBECK_ENABLED, - ), - ), - ParameterDefinition( - "xmin", - -1.0, - label="下位移限位", - quantity="length", - unit="m", - description="理想、弹性或恢复碰撞限位的下边界位置。", - visible_when=(_MECMAS21_LIMITS_ENABLED,), - ), - ParameterDefinition( - "Kbmin", - 1.0e9, - label="下限位刚度", - quantity="translational_stiffness", - unit="N/m", - minimum=0.0, - description="弹性下限位的接触刚度。", - visible_when=(_MECMAS21_ELASTIC_STOP,), - ), - ParameterDefinition( - "Dbmin", - 1.0e4, - label="下限位阻尼", - quantity="translational_damping", - unit="N/(m/s)", - minimum=0.0, - description="弹性下限位的最大接触阻尼。", - visible_when=(_MECMAS21_ELASTIC_STOP,), - ), - ParameterDefinition( - "Pdmin", - 1.0e-4, - label="下限位满阻尼穿透", - quantity="length", - unit="m", - minimum=0.0, - description="弹性下限位阻尼由零增至全值所需的穿透量。", - visible_when=(_MECMAS21_ELASTIC_STOP,), - ), - ParameterDefinition( - "xmax", - 0.8, - label="上位移限位", - quantity="length", - unit="m", - description="理想、弹性或恢复碰撞限位的上边界位置。", - visible_when=(_MECMAS21_LIMITS_ENABLED,), - ), - ParameterDefinition( - "Kbmax", - 1.0e9, - label="上限位刚度", - quantity="translational_stiffness", - unit="N/m", - minimum=0.0, - description="弹性上限位的接触刚度。", - visible_when=(_MECMAS21_ELASTIC_STOP,), - ), - ParameterDefinition( - "Dbmax", - 1.0e4, - label="上限位阻尼", - quantity="translational_damping", - unit="N/(m/s)", - minimum=0.0, - description="弹性上限位的最大接触阻尼。", - visible_when=(_MECMAS21_ELASTIC_STOP,), - ), - ParameterDefinition( - "Pdmax", - 1.0e-4, - label="上限位满阻尼穿透", - quantity="length", - unit="m", - minimum=0.0, - description="弹性上限位阻尼由零增至全值所需的穿透量。", - visible_when=(_MECMAS21_ELASTIC_STOP,), - ), - ParameterDefinition( - "theta", - 0.0, - label="倾角(度)", - quantity="dimensionless", - unit="", - description=( - "源 AMESim 参数以度为单位:+90° 表示端口 1 位于最低端," - "-90° 表示端口 1 位于最高端。当前一维公开求解器尚未加入" - "由该倾角产生的重力分量。" - ), - ), - ParameterDefinition( - "useFriction", - 2.0, - label="启用摩擦", - quantity="dimensionless", - unit="", - editor="choice", - options=( - ParameterOption(1.0, "否"), - ParameterOption(2.0, "是"), - ), - description=( - "AMESim 原始编码:1 表示不启用摩擦,2 表示启用摩擦。" - "当前公开模型在启用时实现黏性摩擦、风阻和基础库仑摩擦;" - "高级静摩擦与 Stribeck 公式尚未实现。" - ), - ), - ParameterDefinition( - "stoptype", - 1.0, - label="限位类型", - quantity="dimensionless", - unit="", - editor="choice", - options=( - ParameterOption(1.0, "理想限位"), - ParameterOption(2.0, "弹性限位"), - ParameterOption(3.0, "恢复碰撞"), - ParameterOption(4.0, "无限位"), - ), - description=( - "AMESim 原始编码:1 为理想限位,2 为弹性限位," - "3 为带恢复系数的碰撞限位,4 为无限位。" - ), - ), - ParameterDefinition( - "discContactOption", - 1.0, - label="允许负接触力", - quantity="dimensionless", - unit="", - editor="choice", - options=( - ParameterOption(1.0, "允许负接触力"), - ParameterOption(2.0, "不允许负接触力"), - ), - description=( - "仅在弹性限位中生效。AMESim 原始编码:1 保留阻尼项可能产生的" - "负接触力,2 将接触力限制为非负。" - ), - visible_when=(_MECMAS21_ELASTIC_STOP,), - ), - ParameterDefinition( - "strib", - 1.0, - label="Stribeck 效应", - quantity="dimensionless", - unit="", - editor="choice", - options=( - ParameterOption(1.0, "否"), - ParameterOption(2.0, "是"), - ), - description=( - "AMESim 原始编码:1 表示不使用 Stribeck 效应,2 表示使用。" - "该选项目前用于保留模型配置和控制参数显示;" - "高级 Stribeck 摩擦公式尚未实现。" - ), - visible_when=( - _MECMAS21_NON_RESTITUTION, - _MECMAS21_FRICTION_ENABLED, - _MECMAS21_ADVANCED_FRICTION, - ), - ), - ParameterDefinition( - "frictionType", - 1.0, - label="摩擦类型", - quantity="dimensionless", - unit="", - editor="choice", - options=( - ParameterOption(1.0, "简单"), - ParameterOption(2.0, "高级"), - ), - description=( - "AMESim 原始编码:1 为简单摩擦,2 为高级摩擦。" - "当前公开模型只实现黏性摩擦、风阻和基础库仑摩擦," - "高级静摩擦与 Stribeck 行为尚未实现。" - ), - visible_when=( - _MECMAS21_NON_RESTITUTION, - _MECMAS21_FRICTION_ENABLED, - ), - ), - ParameterDefinition( - "v0", - 0.0, - label="初始速度", - quantity="velocity", - unit="m/s", - description="仿真开始时质量的平动速度。", - ), - ParameterDefinition( - "x0", - 0.0, - label="初始位移", - quantity="length", - unit="m", - description="仿真开始时质量的平动位置。", - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("a", "加速度", "acceleration", "m/s2", "state", 10), - ResultVariableDefinition("v", "速度", "velocity", "m/s", "state", 20), - ResultVariableDefinition("x", "位移", "length", "m", "state", 30), - ResultVariableDefinition("Fvisc", "黏性摩擦力", "force", "N", "derived", 40), - ResultVariableDefinition("Ffric", "干摩擦力", "force", "N", "derived", 50), - ResultVariableDefinition("Fmin", "下限位力", "force", "N", "derived", 60), - ResultVariableDefinition("Fmax", "上限位力", "force", "N", "derived", 70), - ) - DISPLAY = ComponentDisplaySpec( - label="MECMAS21 一维质量", - library_id="amesim", - category_id="mechanical", - symbol="amesim_mecmas21", - ports=( - PortDisplaySpec("port_2", "left", order=10), - PortDisplaySpec("port_1", "right", order=20), - ), - order=30, - parameter_groups=( - ParameterGroupDisplaySpec( - id="friction", - label="摩擦", - parameters=( - "frictionType", - "strib", - "astrib", - "fstick", - "fcoul", - "rvisc", - "wind", - "dvel", - ), - order=10, - ), - ParameterGroupDisplaySpec( - id="endstops", - label="限位", - parameters=( - "discContactOption", - "xmax", - "Kbmax", - "Dbmax", - "Pdmax", - "xmin", - "Kbmin", - "Dbmin", - "Pdmin", - "restcoeff", - "restdvel", - ), - order=20, - ), - ), - ) + MODEL_TYPE = 'amesim_mecmas21' + MODEL_VERSION = '0.2.0' + PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2')) + PARAMETERS = (ParameterDefinition('mass', 1.0, label='质量', quantity='mass', unit='kg', minimum=0.0, minimum_exclusive=True, description='平动质量,必须大于零。'), ParameterDefinition('fstick', 0.0, label='静摩擦力', quantity='force', unit='N', minimum=0.0, description='AMESim 静摩擦阈值。当前公开求解器尚未实现静摩擦保持与脱离逻辑,因此该参数暂不参与力计算。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('fcoul', 0.0, label='库仑摩擦力', quantity='force', unit='N', minimum=0.0, description='滑动时的库仑摩擦力绝对值,方向与速度相反。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('rvisc', 0.0, label='黏性摩擦系数', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='与速度成正比的黏性摩擦系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('wind', 0.0, label='风阻系数', quantity='windage', unit='N/(m/s)^2', minimum=0.0, description='与速度平方成正比、方向与速度相反的风阻系数。', visible_when=(_MECMAS21_FRICTION_ENABLED,)), ParameterDefinition('dvel', 1e-06, label='粘滞速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='高级摩擦模型的低速切换阈值;当前公开求解器尚未实现对应的高级静摩擦公式。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION)), ParameterDefinition('restdvel', 1e-06, label='恢复速度阈值', quantity='velocity', unit='m/s', minimum=0.0, description='恢复碰撞低于该入射速度时按无回弹处理。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('restcoeff', 0.65, label='恢复系数', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='恢复碰撞后的速度与碰撞前速度绝对值之比。', visible_when=(_MECMAS21_RESTITUTION_STOP,)), ParameterDefinition('astrib', 0.001, label='Stribeck 常数', quantity='velocity', unit='m/s', minimum=0.0, description='高级 Stribeck 摩擦模型的速度常数;当前公开求解器尚未实现对应的 Stribeck 公式。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION, _MECMAS21_STRIBECK_ENABLED)), ParameterDefinition('xmin', -1.0, label='下位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的下边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmin', 1000000000.0, label='下限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性下限位的接触刚度。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmin', 10000.0, label='下限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性下限位的最大接触阻尼。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmin', 0.0001, label='下限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性下限位阻尼由零增至全值所需的穿透量。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('xmax', 0.8, label='上位移限位', quantity='length', unit='m', description='理想、弹性或恢复碰撞限位的上边界位置。', visible_when=(_MECMAS21_LIMITS_ENABLED,)), ParameterDefinition('Kbmax', 1000000000.0, label='上限位刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='弹性上限位的接触刚度。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Dbmax', 10000.0, label='上限位阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='弹性上限位的最大接触阻尼。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('Pdmax', 0.0001, label='上限位满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='弹性上限位阻尼由零增至全值所需的穿透量。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('theta', 0.0, label='倾角(度)', quantity='dimensionless', unit='', description='源 AMESim 参数以度为单位:+90° 表示端口 1 位于最低端,-90° 表示端口 1 位于最高端。当前一维公开求解器尚未加入由该倾角产生的重力分量。'), ParameterDefinition('useFriction', 2.0, label='启用摩擦', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '否'), ParameterOption(2.0, '是')), description='AMESim 原始编码:1 表示不启用摩擦,2 表示启用摩擦。当前公开模型在启用时实现黏性摩擦、风阻和基础库仑摩擦;高级静摩擦与 Stribeck 公式尚未实现。'), ParameterDefinition('stoptype', 1.0, label='限位类型', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '理想限位'), ParameterOption(2.0, '弹性限位'), ParameterOption(3.0, '恢复碰撞'), ParameterOption(4.0, '无限位')), description='AMESim 原始编码:1 为理想限位,2 为弹性限位,3 为带恢复系数的碰撞限位,4 为无限位。'), ParameterDefinition('discContactOption', 1.0, label='允许负接触力', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '允许负接触力'), ParameterOption(2.0, '不允许负接触力')), description='仅在弹性限位中生效。AMESim 原始编码:1 保留阻尼项可能产生的负接触力,2 将接触力限制为非负。', visible_when=(_MECMAS21_ELASTIC_STOP,)), ParameterDefinition('strib', 1.0, label='Stribeck 效应', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '否'), ParameterOption(2.0, '是')), description='AMESim 原始编码:1 表示不使用 Stribeck 效应,2 表示使用。该选项目前用于保留模型配置和控制参数显示;高级 Stribeck 摩擦公式尚未实现。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED, _MECMAS21_ADVANCED_FRICTION)), ParameterDefinition('frictionType', 1.0, label='摩擦类型', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '简单'), ParameterOption(2.0, '高级')), description='AMESim 原始编码:1 为简单摩擦,2 为高级摩擦。当前公开模型只实现黏性摩擦、风阻和基础库仑摩擦,高级静摩擦与 Stribeck 行为尚未实现。', visible_when=(_MECMAS21_NON_RESTITUTION, _MECMAS21_FRICTION_ENABLED)), ParameterDefinition('v0', 0.0, label='初始速度', quantity='velocity', unit='m/s', description='仿真开始时质量的平动速度。'), ParameterDefinition('x0', 0.0, label='初始位移', quantity='length', unit='m', description='仿真开始时质量的平动位置。')) + RESULT_VARIABLES = (ResultVariableDefinition('a', '加速度', 'acceleration', 'm/s2', 'state', 10), ResultVariableDefinition('v', '速度', 'velocity', 'm/s', 'state', 20), ResultVariableDefinition('x', '位移', 'length', 'm', 'state', 30), ResultVariableDefinition('Fvisc', '黏性摩擦力', 'force', 'N', 'derived', 40), ResultVariableDefinition('Ffric', '干摩擦力', 'force', 'N', 'derived', 50), ResultVariableDefinition('Fmin', '下限位力', 'force', 'N', 'derived', 60), ResultVariableDefinition('Fmax', '上限位力', 'force', 'N', 'derived', 70)) + DISPLAY = ComponentDisplaySpec(label='MECMAS21 一维质量', library_id='amesim', category_id='mechanical', symbol='amesim_mecmas21', ports=(PortDisplaySpec('port_2', 'left', order=10), PortDisplaySpec('port_1', 'right', order=20)), order=30, parameter_groups=(ParameterGroupDisplaySpec(id='friction', label='摩擦', parameters=('frictionType', 'strib', 'astrib', 'fstick', 'fcoul', 'rvisc', 'wind', 'dvel'), order=10), ParameterGroupDisplaySpec(id='endstops', label='限位', parameters=('discContactOption', 'xmax', 'Kbmax', 'Dbmax', 'Pdmax', 'xmin', 'Kbmin', 'Dbmin', 'Pdmin', 'restcoeff', 'restdvel'), order=20))) state_size = 2 def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None: @@ -575,977 +73,109 @@ class AmesimMecmas21(DynamicComponent): for name, value in resolved.items(): setattr(self, name, float(value)) self.use_friction = int(self.useFriction) == 2 - self.port_1 = self.register_declared_port("port_1") - self.port_2 = self.register_declared_port("port_2") - self.v = float(self.v0) - self.x = float(self.x0) - self._constraint_acceleration: float | None = None - self._constraint_velocity: float | None = None - self.refresh_thermodynamic_ports() + self.port_1 = self.register_declared_port('port_1') + self.port_2 = self.register_declared_port('port_2') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> "AmesimMecmas21": + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimMecmas21': definitions = {definition.name: definition for definition in cls.PARAMETERS} - for integer_name in ("useFriction", "stoptype", "discContactOption", "strib", "frictionType"): + for integer_name in ('useFriction', 'stoptype', 'discContactOption', 'strib', 'frictionType'): value = float(parameters[integer_name]) if not value.is_integer(): - raise ValueError(f"MECMAS21 {integer_name} must be an integer.") + raise ValueError(f'MECMAS21 {integer_name} must be an integer.') message = definitions[integer_name].validation_message(value) if message is not None: - raise ValueError(f"MECMAS21 {integer_name} {message}.") + raise ValueError(f'MECMAS21 {integer_name} {message}.') return cls(name=name, medium=medium, **dict(parameters)) - - def get_state_vector(self) -> list[float]: - return [self.v, self.x] - - def set_state_vector(self, values: list[float]) -> None: - if len(values) != 2: - raise ValueError("MECMAS21 state vector requires [v, x].") - self.v = float(values[0]) - self.x = float(values[1]) - self._constraint_acceleration = None - self._constraint_velocity = None - self.refresh_thermodynamic_ports() - - def refresh_thermodynamic_ports(self) -> None: - for port in (self.port_1, self.port_2): - port.x = self.x - port.v = self.v - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return ( - self.port_1.x - self.x, - self.port_1.v - self.v, - self.port_2.x - self.x, - self.port_2.v - self.v, - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - self._state_residual("port_1", "x", self.port_1.x - self.x), - self._state_residual("port_1", "v", self.port_1.v - self.v), - self._state_residual("port_2", "x", self.port_2.x - self.x), - self._state_residual("port_2", "v", self.port_2.v - self.v), - ) - - def _state_residual(self, port_name: str, variable: str, value: float) -> EquationResidual: - return EquationResidual( - id=f"{self.name}:{port_name}_{variable}_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.{port_name}.{variable}",), - role="effort", - value=value, - ) - - def _viscous_friction_force(self) -> float: - if not self.use_friction: - return 0.0 - return -self.rvisc * self.v - - def _windage_force(self) -> float: - if not self.use_friction: - return 0.0 - return -self.wind * self.v * abs(self.v) - - def _dry_friction_force(self) -> float: - if not self.use_friction: - return 0.0 - if self.v > 0.0: - return -self.fcoul - if self.v < 0.0: - return self.fcoul - return 0.0 - - def _lower_limit_force(self) -> float: - if int(self.stoptype) != 2: - return 0.0 - penetration = max(self.xmin - self.x, 0.0) - if penetration <= 0.0: - return 0.0 - damping_fraction = ( - min(penetration / self.Pdmin, 1.0) - if self.Pdmin > 0.0 - else 1.0 - ) - force = ( - self.Kbmin * penetration - - damping_fraction * self.Dbmin * self.v - ) - return force if int(self.discContactOption) == 1 else max(force, 0.0) - - def _upper_limit_force(self) -> float: - if int(self.stoptype) != 2: - return 0.0 - penetration = max(self.x - self.xmax, 0.0) - if penetration <= 0.0: - return 0.0 - damping_fraction = ( - min(penetration / self.Pdmax, 1.0) - if self.Pdmax > 0.0 - else 1.0 - ) - force = ( - self.Kbmax * penetration - + damping_fraction * self.Dbmax * self.v - ) - return force if int(self.discContactOption) == 1 else max(force, 0.0) - - def force_without_endstops(self) -> float: - return ( - self.port_1.f - + self.port_2.f - + self._viscous_friction_force() - + self._windage_force() - + self._dry_friction_force() - ) - - def unconstrained_acceleration(self) -> float: - return ( - self.force_without_endstops() - + self._lower_limit_force() - - self._upper_limit_force() - ) / self.mass - - @property - def uses_ideal_endstops(self) -> bool: - return int(self.stoptype) == 1 - - def set_constraint_motion( - self, - acceleration: float | None, - *, - velocity: float | None = None, - ) -> None: - self._constraint_acceleration = ( - None if acceleration is None else float(acceleration) - ) - self._constraint_velocity = None if velocity is None else float(velocity) - - def acceleration(self) -> float: - if self._constraint_acceleration is not None: - return self._constraint_acceleration - return self.unconstrained_acceleration() - - def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: - velocity = ( - self.v - if self._constraint_velocity is None - else self._constraint_velocity - ) - return [self.acceleration(), velocity] - - def linearize_state_derivative( - self, - port_1_force_tangent: Sequence[float], - port_2_force_tangent: Sequence[float], - velocity_tangent: Sequence[float], - position_tangent: Sequence[float], - *, - constraint_mode: str = "current", - boundary_tolerance: float = 1.0e-12, - ) -> Mecmas21DerivativeLinearization: - """Linearize one inertia in a declared fixed mechanical mode.""" - - vectors = tuple( - tuple(float(value) for value in values) - for values in ( - port_1_force_tangent, - port_2_force_tangent, - velocity_tangent, - position_tangent, - ) - ) - widths = {len(values) for values in vectors} - if len(widths) != 1: - raise ValueError("MECMAS21 tangent vectors must have equal lengths.") - width = len(vectors[0]) - invalid_reason: str | None = None - if not all(isfinite(value) for values in vectors for value in values): - invalid_reason = "non_finite_tangent_input" - - requested_mode = constraint_mode - if requested_mode == "current": - fixed = ( - self._constraint_acceleration == 0.0 - and self._constraint_velocity == 0.0 - ) - mode = "fixed" if fixed else "free" - if self._constraint_acceleration is not None and not fixed: - invalid_reason = invalid_reason or ( - "group_acceleration_requires_aggregate" - ) - elif requested_mode == "free": - mode = "free" - elif requested_mode in {"lower", "upper"}: - mode = requested_mode - fixed = ( - self._constraint_acceleration == 0.0 - and self._constraint_velocity == 0.0 - ) - if not fixed: - invalid_reason = invalid_reason or ( - "constraint_mode_not_statically_fixed" - ) - elif requested_mode == "uninitialized": - mode = requested_mode - invalid_reason = invalid_reason or "constraint_mode_uninitialized" - else: - raise ValueError( - "MECMAS21 constraint_mode must be current, free, lower, upper, " - "or uninitialized." - ) - - if mode in {"fixed", "lower", "upper"}: - return Mecmas21DerivativeLinearization( - derivative=(self.acceleration(), 0.0), - tangents=((0.0,) * width, (0.0,) * width), - mode=mode, - valid=invalid_reason is None, - reason=invalid_reason, - ) - - force_1_tangent, force_2_tangent, dv, dx = vectors - acceleration_tangent = [ - force_1_tangent[index] + force_2_tangent[index] - for index in range(width) - ] - if self.use_friction: - for index in range(width): - acceleration_tangent[index] += ( - -self.rvisc * dv[index] - - 2.0 * self.wind * abs(self.v) * dv[index] - ) - if ( - self.fcoul != 0.0 - and abs(self.v) <= boundary_tolerance - and any(value != 0.0 for value in dv) - ): - invalid_reason = invalid_reason or "dry_friction_direction_boundary" - - def add_limit_tangent( - *, - side: str, - stiffness: float, - damping: float, - damping_penetration: float, - bound: float, - damping_sign: float, - force_sign: float, - ) -> None: - nonlocal invalid_reason - if int(self.stoptype) != 2: - return - penetration = ( - bound - self.x if side == "lower" else self.x - bound - ) - penetration_tangent = tuple( - (-value if side == "lower" else value) for value in dx - ) - scale = max(abs(bound), abs(self.x), 1.0) - if penetration <= 0.0: - if ( - abs(penetration) <= boundary_tolerance * scale - and any(value != 0.0 for value in penetration_tangent) - ): - invalid_reason = invalid_reason or ( - f"soft_endstop_mode_boundary:{side}" - ) - return - if damping_penetration > 0.0: - fraction = min(penetration / damping_penetration, 1.0) - if penetration < damping_penetration: - fraction_tangent = tuple( - value / damping_penetration - for value in penetration_tangent - ) - else: - fraction_tangent = (0.0,) * width - if ( - abs(penetration - damping_penetration) - <= boundary_tolerance - * max(abs(damping_penetration), 1.0) - and any(value != 0.0 for value in penetration_tangent) - ): - invalid_reason = invalid_reason or ( - f"soft_endstop_damping_boundary:{side}" - ) - else: - fraction = 1.0 - fraction_tangent = (0.0,) * width - - raw_force = ( - stiffness * penetration - + damping_sign * fraction * damping * self.v - ) - raw_tangent = tuple( - stiffness * penetration_tangent[index] - + damping_sign - * damping - * ( - fraction * dv[index] - + self.v * fraction_tangent[index] - ) - for index in range(width) - ) - if int(self.discContactOption) != 1 and raw_force <= 0.0: - if ( - abs(raw_force) - <= boundary_tolerance - * max(abs(stiffness * penetration), 1.0) - and any(value != 0.0 for value in raw_tangent) - ): - invalid_reason = invalid_reason or ( - f"soft_endstop_force_boundary:{side}" - ) - return - for index in range(width): - acceleration_tangent[index] += ( - force_sign * raw_tangent[index] - ) - - add_limit_tangent( - side="lower", - stiffness=self.Kbmin, - damping=self.Dbmin, - damping_penetration=self.Pdmin, - bound=self.xmin, - damping_sign=-1.0, - force_sign=1.0, - ) - add_limit_tangent( - side="upper", - stiffness=self.Kbmax, - damping=self.Dbmax, - damping_penetration=self.Pdmax, - bound=self.xmax, - damping_sign=1.0, - force_sign=-1.0, - ) - acceleration_tangent = tuple( - value / self.mass for value in acceleration_tangent - ) - return Mecmas21DerivativeLinearization( - derivative=(self.unconstrained_acceleration(), self.v), - tangents=(acceleration_tangent, tuple(dv)), - mode=mode, - valid=invalid_reason is None, - reason=invalid_reason, - ) - - def component_result_values(self) -> Mapping[str, float]: - return { - "a": self.acceleration(), - "v": self.v, - "x": self.x, - "Fvisc": self._viscous_friction_force(), - "Ffric": self._dry_friction_force(), - "Fmin": self._lower_limit_force(), - "Fmax": self._upper_limit_force(), - } - + EQUATIONS = ({'id': '__MODEL__:port_1_x_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.x'], 'role': 'effort'}, {'id': '__MODEL__:port_1_v_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.v'], 'role': 'effort'}, {'id': '__MODEL__:port_2_x_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.x'], 'role': 'effort'}, {'id': '__MODEL__:port_2_v_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.v'], 'role': 'effort'}) class AmesimLstp00a(AlgebraicComponent): """AMESim LSTP00A first public elastic contact component.""" - - MODEL_TYPE = "amesim_lstp00a" - MODEL_VERSION = "0.2.0" - PORTS = ( - PortDefinition.mechanical_translational("port_1"), - PortDefinition.mechanical_translational("port_2"), - ) - PARAMETERS = ( - ParameterDefinition( - "na", - 10.0, - label="有效圈数", - quantity="dimensionless", - unit="", - minimum=0.0, - minimum_exclusive=True, - description="几何刚度模式下使用的弹簧有效圈数。", - visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), - ), - ParameterDefinition( - "gap0", - 0.0, - label="初始间隙", - quantity="length", - unit="m", - description="两个机械端口开始产生接触力前的初始间隙。", - ), - ParameterDefinition( - "kcont", - 1.0e6, - label="接触刚度", - quantity="translational_stiffness", - unit="N/m", - minimum=0.0, - description="数值刚度模式下直接指定的接触刚度。", - visible_when=(_LSTP00A_NUMERICAL_STIFFNESS,), - ), - ParameterDefinition( - "G", - 8.57e10, - label="剪切模量", - quantity="pressure", - unit="Pa", - minimum=0.0, - description="几何刚度模式下的材料剪切模量。", - visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), - ), - ParameterDefinition( - "sdiam", - 0.02, - label="弹簧直径", - quantity="length", - unit="m", - minimum=0.0, - description="几何刚度模式下的弹簧平均直径。", - visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), - ), - ParameterDefinition( - "wdiam", - 0.002, - label="线径", - quantity="length", - unit="m", - minimum=0.0, - description="几何刚度模式下的弹簧线径。", - visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,), - ), - ParameterDefinition( - "rcont", - 0.0, - label="接触阻尼", - quantity="translational_damping", - unit="N/(m/s)", - minimum=0.0, - description="接触穿透过程中使用的最大阻尼系数。", - ), - ParameterDefinition( - "Pdis", - 1.0e-7, - label="满阻尼穿透", - quantity="length", - unit="m", - minimum=0.0, - description="接触阻尼由零增至全值所需的穿透量。", - ), - ParameterDefinition( - "stiffmode", - 1.0, - label="刚度模式", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=2.0, - editor="choice", - options=( - ParameterOption(1.0, "数值刚度"), - ParameterOption(2.0, "几何参数"), - ), - description=( - "AMESim 原始编码:1 直接使用接触刚度,2 使用弹簧几何参数。" - "当前公开求解器尚未实现由几何参数换算刚度,几何模式仍沿用" - "已保存的接触刚度值。" - ), - ), - ParameterDefinition( - "discContactOption", - 1.0, - label="允许负接触力", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=2.0, - editor="choice", - options=( - ParameterOption(1.0, "允许负接触力"), - ParameterOption(2.0, "不允许负接触力"), - ), - description=( - "AMESim 原始编码:1 保留阻尼项可能产生的负接触力," - "2 将接触力限制为非负。" - ), - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("gap", "间隙", "length", "m", "derived", 10), - ResultVariableDefinition("penetration", "穿透", "length", "m", "derived", 20), - ResultVariableDefinition("force", "接触力", "force", "N", "derived", 30), - ) - DISPLAY = ComponentDisplaySpec( - label="LSTP00A 弹性接触", - library_id="amesim", - category_id="mechanical", - symbol="amesim_lstp00a", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=40, - parameter_groups=( - ParameterGroupDisplaySpec( - id="stiffness", - label="刚度", - parameters=("kcont", "G", "sdiam", "wdiam", "na"), - order=10, - ), - ParameterGroupDisplaySpec( - id="contact", - label="接触", - parameters=("gap0", "rcont", "Pdis"), - order=20, - ), - ), - ) + MODEL_TYPE = 'amesim_lstp00a' + MODEL_VERSION = '0.2.0' + PORTS = (PortDefinition.mechanical_translational('port_1'), PortDefinition.mechanical_translational('port_2')) + PARAMETERS = (ParameterDefinition('na', 10.0, label='有效圈数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, description='几何刚度模式下使用的弹簧有效圈数。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('gap0', 0.0, label='初始间隙', quantity='length', unit='m', description='两个机械端口开始产生接触力前的初始间隙。'), ParameterDefinition('kcont', 1000000.0, label='接触刚度', quantity='translational_stiffness', unit='N/m', minimum=0.0, description='数值刚度模式下直接指定的接触刚度。', visible_when=(_LSTP00A_NUMERICAL_STIFFNESS,)), ParameterDefinition('G', 85700000000.0, label='剪切模量', quantity='pressure', unit='Pa', minimum=0.0, description='几何刚度模式下的材料剪切模量。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('sdiam', 0.02, label='弹簧直径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧平均直径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('wdiam', 0.002, label='线径', quantity='length', unit='m', minimum=0.0, description='几何刚度模式下的弹簧线径。', visible_when=(_LSTP00A_GEOMETRICAL_STIFFNESS,)), ParameterDefinition('rcont', 0.0, label='接触阻尼', quantity='translational_damping', unit='N/(m/s)', minimum=0.0, description='接触穿透过程中使用的最大阻尼系数。'), ParameterDefinition('Pdis', 1e-07, label='满阻尼穿透', quantity='length', unit='m', minimum=0.0, description='接触阻尼由零增至全值所需的穿透量。'), ParameterDefinition('stiffmode', 1.0, label='刚度模式', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '数值刚度'), ParameterOption(2.0, '几何参数')), description='AMESim 原始编码:1 直接使用接触刚度,2 使用弹簧几何参数。当前公开求解器尚未实现由几何参数换算刚度,几何模式仍沿用已保存的接触刚度值。'), ParameterDefinition('discContactOption', 1.0, label='允许负接触力', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '允许负接触力'), ParameterOption(2.0, '不允许负接触力')), description='AMESim 原始编码:1 保留阻尼项可能产生的负接触力,2 将接触力限制为非负。')) + RESULT_VARIABLES = (ResultVariableDefinition('gap', '间隙', 'length', 'm', 'derived', 10), ResultVariableDefinition('penetration', '穿透', 'length', 'm', 'derived', 20), ResultVariableDefinition('force', '接触力', 'force', 'N', 'derived', 30)) + DISPLAY = ComponentDisplaySpec(label='LSTP00A 弹性接触', library_id='amesim', category_id='mechanical', symbol='amesim_lstp00a', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=(ParameterGroupDisplaySpec(id='stiffness', label='刚度', parameters=('kcont', 'G', 'sdiam', 'wdiam', 'na'), order=10), ParameterGroupDisplaySpec(id='contact', label='接触', parameters=('gap0', 'rcont', 'Pdis'), order=20))) def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None: super().__init__(name=name) - resolved = { - definition.name: parameters.get(definition.name, definition.default) - for definition in self.PARAMETERS - } + resolved = {definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS} self.set_parameter_values(resolved) for name, value in resolved.items(): setattr(self, name, float(value)) - self.port_1 = self.register_declared_port("port_1") - self.port_2 = self.register_declared_port("port_2") - self._causal_penetration: float | None = None - self._causal_contact_force: float | None = None - self._causal_port_1_x: float | None = None - self._causal_port_2_x: float | None = None - self._causal_port_1_v: float | None = None - self._causal_port_2_v: float | None = None + self.port_1 = self.register_declared_port('port_1') + self.port_2 = self.register_declared_port('port_2') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> "AmesimLstp00a": + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimLstp00a': definitions = {definition.name: definition for definition in cls.PARAMETERS} - for integer_name in ("stiffmode", "discContactOption"): + for integer_name in ('stiffmode', 'discContactOption'): value = float(parameters[integer_name]) if not value.is_integer(): - raise ValueError(f"LSTP00A {integer_name} must be an integer.") + raise ValueError(f'LSTP00A {integer_name} must be an integer.') message = definitions[integer_name].validation_message(value) if message is not None: - raise ValueError(f"LSTP00A {integer_name} {message}.") + raise ValueError(f'LSTP00A {integer_name} {message}.') return cls(name=name, medium=medium, **dict(parameters)) - - @property - def gap(self) -> float: - if self._causal_penetration is not None: - assert self._causal_port_1_x is not None - assert self._causal_port_2_x is not None - penetration = ( - self._causal_penetration - + (self.port_1.x - self._causal_port_1_x) - - (self.port_2.x - self._causal_port_2_x) - ) - return -penetration - return self.gap0 + (self.port_2.x - self.port_1.x) - - @property - def penetration(self) -> float: - return max(-self.gap, 0.0) - - @property - def penetration_velocity(self) -> float: - return self.port_1.v - self.port_2.v - - @property - def contact_force(self) -> float: - if self._causal_contact_force is not None: - assert self._causal_port_1_x is not None - assert self._causal_port_2_x is not None - assert self._causal_port_1_v is not None - assert self._causal_port_2_v is not None - if ( - self.port_1.x == self._causal_port_1_x - and self.port_2.x == self._causal_port_2_x - and self.port_1.v == self._causal_port_1_v - and self.port_2.v == self._causal_port_2_v - ): - return self._causal_contact_force - return self.contact_force_for_penetration(self.penetration) - - def contact_force_for_penetration(self, penetration: float) -> float: - penetration = max(float(penetration), 0.0) - if penetration <= 0.0: - return 0.0 - damping_fraction = ( - -expm1(-penetration / self.Pdis) - if self.Pdis > 0.0 - else 1.0 - ) - force = ( - self.kcont * penetration - + damping_fraction * self.rcont * self.penetration_velocity - ) - return force if int(self.discContactOption) == 1 else max(force, 0.0) - - def linearize_contact_force( - self, - port_1_x_tangent: Sequence[float], - port_2_x_tangent: Sequence[float], - port_1_velocity_tangent: Sequence[float], - port_2_velocity_tangent: Sequence[float], - *, - boundary_tolerance: float = 1.0e-12, - ) -> LstpContactForceLinearization: - """Linearize the elastic contact in its current unilateral mode.""" - - vectors = tuple( - tuple(float(value) for value in values) - for values in ( - port_1_x_tangent, - port_2_x_tangent, - port_1_velocity_tangent, - port_2_velocity_tangent, - ) - ) - widths = {len(values) for values in vectors} - if len(widths) != 1: - raise ValueError("LSTP00A tangent vectors must have equal lengths.") - width = len(vectors[0]) - if not all(isfinite(value) for values in vectors for value in values): - return LstpContactForceLinearization( - force=self.contact_force, - force_tangent=(0.0,) * width, - mode="invalid", - valid=False, - reason="non_finite_tangent_input", - ) - - dx_1, dx_2, dv_1, dv_2 = vectors - penetration_tangent = tuple( - left - right for left, right in zip(dx_1, dx_2, strict=True) - ) - velocity_tangent = tuple( - left - right for left, right in zip(dv_1, dv_2, strict=True) - ) - overlap = -self.gap - force = self.contact_force - scale = max(abs(self.gap0), abs(self.port_1.x), abs(self.port_2.x), 1.0) - if overlap <= 0.0: - on_boundary = abs(overlap) <= boundary_tolerance * scale - crossing = any(value != 0.0 for value in penetration_tangent) - return LstpContactForceLinearization( - force=force, - force_tangent=(0.0,) * width, - mode="boundary" if on_boundary else "inactive", - valid=not (on_boundary and crossing), - reason=( - "contact_mode_boundary" - if on_boundary and crossing - else None - ), - ) - - penetration = overlap - if self.Pdis > 0.0: - damping_fraction = -expm1(-penetration / self.Pdis) - damping_fraction_tangent = tuple( - (1.0 - damping_fraction) * value / self.Pdis - for value in penetration_tangent - ) - else: - damping_fraction = 1.0 - damping_fraction_tangent = (0.0,) * width - relative_velocity = self.penetration_velocity - raw_force = ( - self.kcont * penetration - + damping_fraction * self.rcont * relative_velocity - ) - raw_tangent = tuple( - self.kcont * penetration_tangent[index] - + self.rcont - * ( - damping_fraction * velocity_tangent[index] - + relative_velocity * damping_fraction_tangent[index] - ) - for index in range(width) - ) - if int(self.discContactOption) != 1 and raw_force <= 0.0: - on_boundary = ( - abs(raw_force) - <= boundary_tolerance - * max(abs(self.kcont * penetration), 1.0) - ) - crossing = any(value != 0.0 for value in raw_tangent) - return LstpContactForceLinearization( - force=force, - force_tangent=(0.0,) * width, - mode="force_boundary" if on_boundary else "clamped", - valid=not (on_boundary and crossing), - reason=( - "contact_force_boundary" - if on_boundary and crossing - else None - ), - ) - return LstpContactForceLinearization( - force=force, - force_tangent=raw_tangent, - mode="active", - ) - - def clear_causal_contact(self) -> None: - self._causal_penetration = None - self._causal_contact_force = None - self._causal_port_1_x = None - self._causal_port_2_x = None - self._causal_port_1_v = None - self._causal_port_2_v = None - - def set_causal_contact(self, *, penetration: float, force: float) -> None: - """Retain a locally causalized contact pair without cancellation. - - A massless contact can require a penetration many orders of magnitude - smaller than either absolute port coordinate. Reconstructing that - penetration by subtracting the two coordinates can then lose the - information entirely. The algebraic solver has already solved this - constitutive pair, so retain it for the rest of the current closure. - """ - - self._causal_penetration = max(float(penetration), 0.0) - self._causal_contact_force = ( - float(force) - if int(self.discContactOption) == 1 - else max(float(force), 0.0) - ) - self._causal_port_1_x = float(self.port_1.x) - self._causal_port_2_x = float(self.port_2.x) - self._causal_port_1_v = float(self.port_1.v) - self._causal_port_2_v = float(self.port_2.v) - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - force = self.contact_force - return ( - self.port_1.f - force, - self.port_2.f + force, - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - force = self.contact_force - return ( - EquationResidual( - id=f"{self.name}:port_1_contact_force", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_1.f", - f"{self.name}.port_1.x", - f"{self.name}.port_1.v", - f"{self.name}.port_2.x", - f"{self.name}.port_2.v", - ), - role="flow", - value=self.port_1.f - force, - ), - EquationResidual( - id=f"{self.name}:port_2_contact_force", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_2.f", - f"{self.name}.port_1.x", - f"{self.name}.port_1.v", - f"{self.name}.port_2.x", - f"{self.name}.port_2.v", - ), - role="flow", - value=self.port_2.f + force, - ), - ) - - def component_result_values(self) -> Mapping[str, float]: - return { - "gap": self.gap, - "penetration": self.penetration, - "force": self.contact_force, - } - + EQUATIONS = ({'id': '__MODEL__:port_1_contact_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_1.f', '__MODEL__.port_1.x', '__MODEL__.port_1.v', '__MODEL__.port_2.x', '__MODEL__.port_2.v'], 'role': 'flow'}, {'id': '__MODEL__:port_2_contact_force', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_2.f', '__MODEL__.port_1.x', '__MODEL__.port_1.v', '__MODEL__.port_2.x', '__MODEL__.port_2.v'], 'role': 'flow'}) class AmesimLmechn1(AlgebraicComponent): """AMESim LMECHN1 first public dynamic linear mechanical node.""" + MODEL_TYPE = 'amesim_lmechn1' + MODEL_VERSION = '0.2.0' + PORTS = tuple((PortDefinition.mechanical_translational(f'port_{index}') for index in range(1, 22))) + PARAMETERS = (ParameterDefinition('v1', 2.0, label='右侧端口数', quantity='dimensionless', unit='', minimum=1.0, maximum=20.0, description='设置工作区中显示的右侧机械端口数量,最多 20 个。'), ParameterDefinition('sum', 1.0, label='节点求和模式', quantity='dimensionless', unit='', editor='choice', options=(ParameterOption(1.0, '各端口力代数和为零(标准节点)'),))) + RESULT_VARIABLES = (ResultVariableDefinition('tforce', '节点合力', 'force', 'N', 'derived', 10),) + DISPLAY = ComponentDisplaySpec(label='LMECHN1 线性机械节点', library_id='amesim', category_id='mechanical', symbol='amesim_lmechn1', ports=tuple([PortDisplaySpec(f'port_{index}', 'right', order=index * 10) for index in range(1, 21)] + [PortDisplaySpec('port_21', 'left', order=210)]), order=50) - MODEL_TYPE = "amesim_lmechn1" - MODEL_VERSION = "0.2.0" - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("force_balance",) - ) - PORTS = tuple( - PortDefinition.mechanical_translational(f"port_{index}") - for index in range(1, 22) - ) - PARAMETERS = ( - ParameterDefinition( - "v1", - 2.0, - label="右侧端口数", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=20.0, - description="设置工作区中显示的右侧机械端口数量,最多 20 个。", - ), - ParameterDefinition( - "sum", - 1.0, - label="节点求和模式", - quantity="dimensionless", - unit="", - editor="choice", - options=( - ParameterOption(1.0, "各端口力代数和为零(标准节点)"), - ), - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10), - ) - DISPLAY = ComponentDisplaySpec( - label="LMECHN1 线性机械节点", - library_id="amesim", - category_id="mechanical", - symbol="amesim_lmechn1", - ports=tuple( - [ - PortDisplaySpec(f"port_{index}", "right", order=index * 10) - for index in range(1, 21) - ] - + [PortDisplaySpec("port_21", "left", order=210)] - ), - order=50, - ) - - def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 2.0, sum: float = 1.0) -> None: + def __init__(self, name: str, medium: IdealGasMedium, *, v1: float=2.0, sum: float=1.0) -> None: super().__init__(name=name) - self.set_parameter_values({"v1": v1, "sum": sum}) + self.set_parameter_values({'v1': v1, 'sum': sum}) self.v1 = int(v1) self.sum = int(sum) for definition in self.PORTS: setattr(self, definition.name, self.register_declared_port(definition.name)) @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> "AmesimLmechn1": - for integer_name in ("v1", "sum"): + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimLmechn1': + for integer_name in ('v1', 'sum'): if not float(parameters[integer_name]).is_integer(): - raise ValueError(f"LMECHN1 {integer_name} must be an integer.") - return cls(name=name, medium=medium, v1=parameters["v1"], sum=parameters["sum"]) + raise ValueError(f'LMECHN1 {integer_name} must be an integer.') + return cls(name=name, medium=medium, v1=parameters['v1'], sum=parameters['sum']) @classmethod - def active_port_definitions_for_parameters( - cls, - parameters: Mapping[str, float], - ) -> tuple[PortDefinition, ...]: - right_port_count = float(parameters["v1"]) + def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]: + right_port_count = float(parameters['v1']) if not right_port_count.is_integer(): - raise ValueError("LMECHN1 v1 must be an integer.") + raise ValueError('LMECHN1 v1 must be an integer.') count = int(right_port_count) if count < 1 or count > 20: - raise ValueError("LMECHN1 v1 must be between 1 and 20.") - return cls.PORTS[: count + 1] + raise ValueError('LMECHN1 v1 must be between 1 and 20.') + return cls.PORTS[:count + 1] @property def active_port_definitions(self) -> tuple[PortDefinition, ...]: - return self.PORTS[: self.v1 + 1] + return self.PORTS[:self.v1 + 1] @property def active_ports(self) -> tuple[str, ...]: - return tuple( - definition.name for definition in self.active_port_definitions - ) + return tuple((definition.name for definition in self.active_port_definitions)) @property def reference_port_name(self) -> str: - return f"port_{self.v1 + 1}" + return f'port_{self.v1 + 1}' @property def required_connection_ports(self) -> tuple[str, ...]: return self.active_ports - @property - def total_force(self) -> float: - # AMESim's ``tforce`` is the force transmitted by the summed branch - # ports (1..v1). The final active port is the balancing/common port and - # is excluded from that reported value. - return sum(self.get_port(port_name).f for port_name in self.active_ports[:-1]) - - @property - def force_balance(self) -> float: - return self.total_force + self.get_port(self.reference_port_name).f - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - reference = self.get_port(self.reference_port_name) - values: list[float] = [] - for port_name in self.active_ports[:-1]: - port = self.get_port(port_name) - values.extend((port.x - reference.x, port.v - reference.v)) - values.append(self.force_balance) - return tuple(values) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - reference_name = self.reference_port_name - reference = self.get_port(reference_name) - residuals: list[EquationResidual] = [] - for port_name in self.active_ports[:-1]: - port = self.get_port(port_name) - residuals.append( - EquationResidual( - id=f"{self.name}:{port_name}_x_equal", - owner="component", - owner_id=self.name, - relation="equal", - variables=(f"{self.name}.{port_name}.x", f"{self.name}.{reference_name}.x"), - role="effort", - value=port.x - reference.x, - ) - ) - residuals.append( - EquationResidual( - id=f"{self.name}:{port_name}_v_equal", - owner="component", - owner_id=self.name, - relation="equal", - variables=(f"{self.name}.{port_name}.v", f"{self.name}.{reference_name}.v"), - role="effort", - value=port.v - reference.v, - ) - ) - residuals.append( - EquationResidual( - id=f"{self.name}:force_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=tuple(f"{self.name}.{port_name}.f" for port_name in self.active_ports), - role="flow", - value=self.force_balance, - ) - ) - return tuple(residuals) - - def component_result_values(self) -> Mapping[str, float]: - return {"tforce": self.total_force} + def equation_definitions(self): + from app.simulation.core.equations import EquationDefinition + result = [] + reference = self.reference_port_name + for name in self.active_ports[:-1]: + for field in ('x', 'v'): + result.append(EquationDefinition(id=f'{self.name}:{name}_{field}_equal', owner='component', owner_id=self.name, relation='equal', variables=(f'{self.name}.{name}.{field}', f'{self.name}.{reference}.{field}'), role='effort')) + result.append(EquationDefinition(id=f'{self.name}:force_balance', owner='component', owner_id=self.name, relation='sumToZero', variables=tuple((f'{self.name}.{name}.f' for name in self.active_ports)), role='flow')) + return tuple(result) diff --git a/app/simulation/components/amesim/media/mediums.py b/app/simulation/components/amesim/media/mediums.py index 0256f4f..78eb605 100644 --- a/app/simulation/components/amesim/media/mediums.py +++ b/app/simulation/components/amesim/media/mediums.py @@ -1,488 +1,42 @@ +"""Gas identities and constants passed to the native compiler.""" from __future__ import annotations - -from collections.abc import Callable, Sequence +from collections.abc import Callable from dataclasses import dataclass -from math import exp, isfinite, log from typing import ClassVar - -from app.simulation.core.errors import RecoverableTrialStateError -from app.simulation.core.medium import ( - GasMedium, - IdealGasMedium, - ThermodynamicProperties, - ThermodynamicPropertiesLinearization, - ThermodynamicPropertyTangents, -) -from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid -from app.simulation.performance import profile_property, record_property_iterations -from app.simulation.property_cache import cache_property_calculation - +from app.simulation.core.medium import GasMedium, IdealGasMedium @dataclass(frozen=True) class AmesimIdealAirMedium(IdealGasMedium): - """AMESim air properties evaluated with the ideal-gas method. - - Substance identity and property method are part of the concrete Python - type. A future air correlation or helium Peng-Robinson implementation can - therefore coexist as a sibling type without turning ``gi`` into a fluid - enumeration. - """ - - SUBSTANCE_ID: ClassVar[str] = "air" - PROPERTY_METHOD_ID: ClassVar[str] = "ideal_gas" - - name: str = "AMESimAirIdealGas" + SUBSTANCE_ID: ClassVar[str] = 'air' + PROPERTY_METHOD_ID: ClassVar[str] = 'ideal_gas' + name: str = 'AMESimAirIdealGas' R_gas: float = 287.0 cp_ref: float = 1005.0 T_ref: float = 300.0 cp_slope: float = 0.0 - viscosity_ref: float = 1.82e-5 + viscosity_ref: float = 1.82e-05 viscosity_T_ref: float = 293.15 sutherland_constant: float = 110.4 - @dataclass(frozen=True) class AmesimHeliumPengRobinsonMedium(IdealGasMedium): - """AMESim helium with a Peng-Robinson mechanical equation of state. - - The pressure-density-temperature relation is evaluated by the shared - ``HELIUM_PR`` fluid. The caloric reference follows the constant NASA - polynomial from Simcenter Amesim 2404 ``helium_cp_h_s.data``. - """ - - SUBSTANCE_ID: ClassVar[str] = "helium" - PROPERTY_METHOD_ID: ClassVar[str] = "peng_robinson" - fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR + SUBSTANCE_ID: ClassVar[str] = 'helium' + PROPERTY_METHOD_ID: ClassVar[str] = 'peng_robinson' nasa_cp_over_R: ClassVar[float] = 2.5 nasa_enthalpy_constant_K: ClassVar[float] = -745.375 - nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = ( - 0.7501594, - 35.76324, - -2212.129, - 0.9212635, - ) - - name: str = "AMESimHeliumPengRobinson" - R_gas: float = HELIUM_PR.specific_gas_constant - cp_ref: float = nasa_cp_over_R * HELIUM_PR.specific_gas_constant + nasa_viscosity_coefficients: ClassVar[tuple[float, float, float, float]] = (0.7501594, 35.76324, -2212.129, 0.9212635) + name: str = 'AMESimHeliumPengRobinson' + R_gas: float = 8.31446261815324 / 0.004002602 + cp_ref: float = 2.5 * (8.31446261815324 / 0.004002602) T_ref: float = 293.15 cp_slope: float = 0.0 - viscosity_ref: float = 1.96e-5 + viscosity_ref: float = 1.96e-05 viscosity_T_ref: float = 293.15 sutherland_constant: float = 79.4 - @property - def cv(self) -> float: - return (self.nasa_cp_over_R - 1.0) * self.R_gas - - def cv_at_temperature(self, T: float) -> float: - del T - return self.cv - - def diagnostic_dynamic_viscosity(self, T: float) -> float: - """Return the AMESim NASA-table viscosity used by pipe diagnostics. - - pn2pipefr reports Reynolds number with sagum viscosity. Keep this - separate from dynamic_viscosity so matching that diagnostic cannot - alter the already-validated pipe flow or friction dynamics. - """ - - if T <= 0.0: - raise ValueError("Temperature must be positive.") - a, b, c, d = self.nasa_viscosity_coefficients - return 1.0e-7 * exp(a * log(T) + b / T + c / (T * T) + d) - - @profile_property("density") - @cache_property_calculation("density") - def density(self, p: float, T: float) -> float: - return self.fluid.density(p, T) - - def _real_heat_capacities( - self, - p: float, - T: float, - ) -> tuple[float, float, float, float, float]: - density = self.density(p, T) - pressure_density_derivative = ( - self.fluid.pressure_density_derivative_at_temperature( - T, - density, - ) - ) - pressure_temperature_derivative = ( - self.fluid.pressure_temperature_derivative_at_density( - T, - density, - ) - ) - cv = ( - self.cv_at_temperature(T) - + self.fluid.residual_isochoric_heat_capacity_at_density(T, density) - ) - cp = ( - cv - + T - * pressure_temperature_derivative - * pressure_temperature_derivative - / (density * density * pressure_density_derivative) - ) - if cp <= 0.0 or cv <= 0.0: - raise ValueError("Real-gas heat capacities must be positive.") - return ( - cp, - cv, - density, - pressure_density_derivative, - pressure_temperature_derivative, - ) - - def _local_isentropic_density_pressure_factor( - self, - p: float, - T: float, - ) -> tuple[float, float]: - cp, cv, density, pressure_density_derivative, pressure_temperature_derivative = ( - self._real_heat_capacities(p, T) - ) - heat_capacity_ratio = cp / cv - factor = p / ( - density * pressure_density_derivative * heat_capacity_ratio - ) - exponent = ( - p - * (heat_capacity_ratio - 1.0) - / ( - heat_capacity_ratio - * T - * pressure_temperature_derivative - ) - ) - return factor, exponent - - @profile_property("isentropic_density_pressure_factor") - @cache_property_calculation("isentropic_density_pressure_factor") - def isentropic_density_pressure_factor( - self, - p: float, - T: float, - downstream_pressure: float | None = None, - ) -> float: - upstream_factor, isentropic_temperature_exponent = ( - self._local_isentropic_density_pressure_factor(p, T) - ) - if downstream_pressure is None or downstream_pressure >= p: - return upstream_factor - - pressure_ratio = max(downstream_pressure / p, 1.0e-12) - isentropic_temperature = max( - T * pressure_ratio**isentropic_temperature_exponent, - 2.2, - ) - downstream_factor, _unused_exponent = ( - self._local_isentropic_density_pressure_factor( - max(downstream_pressure, 1.0), - isentropic_temperature, - ) - ) - # AMESim 2404 saggs_ evaluates the local factor at the upstream - # state and at an approximate isentropic downstream state. - return 0.5 * (upstream_factor + downstream_factor) - - def pressure(self, m: float, T: float, V: float) -> float: - if V <= 0.0: - raise ValueError("Volume must stay positive.") - return self.fluid.pressure_from_density(T, m / V) - - @profile_property("specific_internal_energy") - def specific_internal_energy(self, T: float) -> float: - return self.R_gas * ( - (self.nasa_cp_over_R - 1.0) * T - + self.nasa_enthalpy_constant_K - ) - - @profile_property("specific_internal_energy_at_pressure") - def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: - density = self.density(p, T) - return ( - self.specific_internal_energy(T) - + self.fluid.residual_specific_internal_energy_at_density(T, density) - ) - - @profile_property("specific_enthalpy") - def specific_enthalpy(self, T: float) -> float: - return self.R_gas * ( - self.nasa_cp_over_R * T - + self.nasa_enthalpy_constant_K - ) - - @profile_property("specific_enthalpy_at_pressure") - def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: - return self.specific_enthalpy(T) + self.fluid.residual_specific_enthalpy(p, T) - - def temperature_from_internal_energy(self, u: float) -> float: - return ( - u / self.R_gas - self.nasa_enthalpy_constant_K - ) / (self.nasa_cp_over_R - 1.0) - - def temperature_from_enthalpy(self, h: float) -> float: - return ( - h / self.R_gas - self.nasa_enthalpy_constant_K - ) / self.nasa_cp_over_R - - @profile_property("temperature_from_pressure_enthalpy") - @cache_property_calculation("temperature_from_pressure_enthalpy") - def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: - temperature = max(self.temperature_from_enthalpy(h), 2.2) - for _iteration in range(16): - residual_enthalpy = self.fluid.residual_specific_enthalpy(p, temperature) - next_temperature = max( - self.temperature_from_enthalpy(h - residual_enthalpy), - 2.2, - ) - if abs(next_temperature - temperature) <= 1.0e-10 * max( - temperature, - 1.0, - ): - record_property_iterations( - "temperature_from_pressure_enthalpy", - _iteration + 1, - True, - ) - return next_temperature - temperature = next_temperature - record_property_iterations( - "temperature_from_pressure_enthalpy", - 16, - False, - ) - return temperature - - def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: - if m <= 0.0: - raise RecoverableTrialStateError( - "Mass must stay positive when recovering temperature." - ) - return self.temperature_from_internal_energy(U / m) - - @profile_property("properties_from_mU") - @cache_property_calculation("properties_from_mU") - def properties_from_mU( - self, - m: float, - U: float, - V: float, - ) -> ThermodynamicProperties: - """Recover a real-gas state, reusing exact repeated evaluations. - - Implicit integration asks several component interfaces for the same - ``(m, U, V)`` state while closing one RHS evaluation and while building - finite-difference Jacobians. The calculation is pure and its result is - immutable, so an exact-key bounded cache avoids repeating the - Peng-Robinson temperature iteration without changing model semantics. - """ - if m <= 0.0: - raise RecoverableTrialStateError( - "Mass must stay positive when recovering temperature." - ) - if V <= 0.0: - raise ValueError("Volume must stay positive.") - density = m / V - target_internal_energy = U / m - temperature = max( - self.temperature_from_internal_energy(target_internal_energy), - 2.2, - ) - converged = False - for _iteration in range(16): - residual_internal_energy = ( - self.fluid.residual_specific_internal_energy_at_density( - temperature, - density, - ) - ) - next_temperature = max( - self.temperature_from_internal_energy( - target_internal_energy - residual_internal_energy - ), - 2.2, - ) - if abs(next_temperature - temperature) <= 1.0e-10 * max( - temperature, - 1.0, - ): - temperature = next_temperature - converged = True - break - temperature = next_temperature - record_property_iterations( - "properties_from_mU", - _iteration + 1, - converged, - ) - pressure = self.fluid.pressure_from_density(temperature, density) - return ThermodynamicProperties( - p=pressure, - T=temperature, - rho=density, - u=target_internal_energy, - h=self.specific_enthalpy_at_pressure( - pressure, - temperature, - ), - ) - - def linearize_properties_from_mU( - self, - m: float, - U: float, - V: float, - dm: Sequence[float], - dU: Sequence[float], - dV: Sequence[float], - *, - properties: ThermodynamicProperties | None = None, - ) -> ThermodynamicPropertiesLinearization: - """Implicitly differentiate the Peng-Robinson m/U/V recovery.""" - - dm_values = tuple(float(value) for value in dm) - dU_values = tuple(float(value) for value in dU) - dV_values = tuple(float(value) for value in dV) - if not (len(dm_values) == len(dU_values) == len(dV_values)): - raise ValueError("Thermodynamic tangent vectors must have equal lengths.") - props = properties or self.properties_from_mU(m, U, V) - width = len(dm_values) - - def invalid(reason: str) -> ThermodynamicPropertiesLinearization: - return ThermodynamicPropertiesLinearization( - properties=props, - tangents=ThermodynamicPropertyTangents.zeros(width), - valid=False, - reason=reason, - ) - - expected_density = m / V - expected_internal_energy = U / m - if ( - abs(props.rho - expected_density) - > 1.0e-12 * max(abs(expected_density), 1.0) - or abs(props.u - expected_internal_energy) - > 1.0e-12 * max(abs(expected_internal_energy), 1.0) - ): - return invalid("properties_primal_mismatch") - if not all( - isfinite(value) - for values in (dm_values, dU_values, dV_values) - for value in values - ): - return invalid("non_finite_tangent_input") - if props.T <= 2.2 * (1.0 + 1.0e-10): - return invalid("temperature_floor_boundary") - - pressure_temperature_derivative = ( - self.fluid.pressure_temperature_derivative_at_density( - props.T, - props.rho, - ) - ) - pressure_density_derivative = ( - self.fluid.pressure_density_derivative_at_temperature( - props.T, - props.rho, - ) - ) - cv = ( - self.cv_at_temperature(props.T) - + self.fluid.residual_isochoric_heat_capacity_at_density( - props.T, - props.rho, - ) - ) - recovered_internal_energy = ( - self.specific_internal_energy(props.T) - + self.fluid.residual_specific_internal_energy_at_density( - props.T, - props.rho, - ) - ) - recovery_scale = max( - abs(props.u), - abs(cv * props.T) if isfinite(cv) else 0.0, - 1.0, - ) - if ( - not all( - isfinite(value) - for value in ( - pressure_temperature_derivative, - pressure_density_derivative, - cv, - recovered_internal_energy, - ) - ) - or cv <= 0.0 - ): - return invalid("invalid_peng_robinson_derivative") - if abs(recovered_internal_energy - props.u) > 1.0e-8 * recovery_scale: - return invalid("properties_recovery_not_converged") - - internal_energy_density_derivative = ( - props.p - props.T * pressure_temperature_derivative - ) / (props.rho * props.rho) - drho: list[float] = [] - du: list[float] = [] - dT: list[float] = [] - dp: list[float] = [] - dh: list[float] = [] - for mass_tangent, energy_tangent, volume_tangent in zip( - dm_values, - dU_values, - dV_values, - strict=True, - ): - density_tangent = ( - mass_tangent / V - m * volume_tangent / (V * V) - ) - internal_energy_tangent = ( - energy_tangent / m - U * mass_tangent / (m * m) - ) - temperature_tangent = ( - internal_energy_tangent - - internal_energy_density_derivative * density_tangent - ) / cv - pressure_tangent = ( - pressure_temperature_derivative * temperature_tangent - + pressure_density_derivative * density_tangent - ) - enthalpy_tangent = ( - internal_energy_tangent - + pressure_tangent / props.rho - - props.p * density_tangent / (props.rho * props.rho) - ) - drho.append(density_tangent) - du.append(internal_energy_tangent) - dT.append(temperature_tangent) - dp.append(pressure_tangent) - dh.append(enthalpy_tangent) - - tangent_values = (*drho, *du, *dT, *dp, *dh) - if not all(isfinite(value) for value in tangent_values): - return invalid("non_finite_property_tangent") - return ThermodynamicPropertiesLinearization( - properties=props, - tangents=ThermodynamicPropertyTangents( - p=tuple(dp), - T=tuple(dT), - rho=tuple(drho), - u=tuple(du), - h=tuple(dh), - ), - ) - - @dataclass(frozen=True) class AmesimGasPropertyModelSpec: """A selectable calculation method for one AMESim gas substance.""" - value: int label: str method_id: str @@ -491,27 +45,7 @@ class AmesimGasPropertyModelSpec: def build_medium(self) -> GasMedium: return self.factory() - - AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL = 0 -AMESIM_AIR_PROPERTY_MODELS = ( - AmesimGasPropertyModelSpec( - value=AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL, - label="理想气体", - method_id=AmesimIdealAirMedium.PROPERTY_METHOD_ID, - factory=AmesimIdealAirMedium, - eos_type=1, - ), -) - - +AMESIM_AIR_PROPERTY_MODELS = (AmesimGasPropertyModelSpec(value=AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL, label='理想气体', method_id=AmesimIdealAirMedium.PROPERTY_METHOD_ID, factory=AmesimIdealAirMedium, eos_type=1),) AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL = 0 -AMESIM_HELIUM_PROPERTY_MODELS = ( - AmesimGasPropertyModelSpec( - value=AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL, - label="Peng–Robinson", - method_id=AmesimHeliumPengRobinsonMedium.PROPERTY_METHOD_ID, - factory=AmesimHeliumPengRobinsonMedium, - eos_type=6, - ), -) +AMESIM_HELIUM_PROPERTY_MODELS = (AmesimGasPropertyModelSpec(value=AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL, label='Peng–Robinson', method_id=AmesimHeliumPengRobinsonMedium.PROPERTY_METHOD_ID, factory=AmesimHeliumPengRobinsonMedium, eos_type=6),) diff --git a/app/simulation/components/amesim/signals/sources.py b/app/simulation/components/amesim/signals/sources.py index cb00eb5..033d37d 100644 --- a/app/simulation/components/amesim/signals/sources.py +++ b/app/simulation/components/amesim/signals/sources.py @@ -1,355 +1,79 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping -from math import floor - from app.simulation.core.base import AlgebraicComponent -from app.simulation.core.catalog import ( - ComponentDisplaySpec, - ParameterGroupDisplaySpec, - PortDisplaySpec, -) -from app.simulation.core.metadata import ( - ParameterCondition, - ParameterDefinition, - ParameterOption, - ResultVariableDefinition, -) +from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisplaySpec, PortDisplaySpec +from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition - - def _ud00_stage_parameters(index: int) -> tuple[ParameterDefinition, ...]: - visible_when = ( - () - if index == 1 - else ( - ParameterCondition( - "nstages", - tuple(float(stage_count) for stage_count in range(index, 9)), - ), - ) - ) - return ( - ParameterDefinition( - f"start{index}", - 0.0 if index == 1 else 1.0, - label=f"第 {index} 段起点", - quantity="dimensionless", - unit="", - description=f"第 {index} 段开始时的输出值。", - visible_when=visible_when, - ), - ParameterDefinition( - f"end{index}", - 1.0, - label=f"第 {index} 段终点", - quantity="dimensionless", - unit="", - description=f"第 {index} 段结束时的输出值。", - visible_when=visible_when, - ), - ParameterDefinition( - f"t{index}", - 1.0 if index == 1 else 0.0, - label=f"第 {index} 段时长", - quantity="time", - unit="s", - minimum=0.0, - description=f"第 {index} 段的持续时间。", - visible_when=visible_when, - ), - ) - - -_UD00_STAGE_PARAMETERS = tuple( - parameter - for stage_index in range(1, 9) - for parameter in _ud00_stage_parameters(stage_index) -) + visible_when = () if index == 1 else (ParameterCondition('nstages', tuple((float(stage_count) for stage_count in range(index, 9)))),) + return (ParameterDefinition(f'start{index}', 0.0 if index == 1 else 1.0, label=f'第 {index} 段起点', quantity='dimensionless', unit='', description=f'第 {index} 段开始时的输出值。', visible_when=visible_when), ParameterDefinition(f'end{index}', 1.0, label=f'第 {index} 段终点', quantity='dimensionless', unit='', description=f'第 {index} 段结束时的输出值。', visible_when=visible_when), ParameterDefinition(f't{index}', 1.0 if index == 1 else 0.0, label=f'第 {index} 段时长', quantity='time', unit='s', minimum=0.0, description=f'第 {index} 段的持续时间。', visible_when=visible_when)) +_UD00_STAGE_PARAMETERS = tuple((parameter for stage_index in range(1, 9) for parameter in _ud00_stage_parameters(stage_index))) class AmesimStep0(AlgebraicComponent): """AMESim STEP0 scalar step signal source.""" + MODEL_TYPE = 'amesim_step0' + MODEL_VERSION = '0.1.0' + PORTS = (PortDefinition.signal('out', nominal_role='output'),) + PARAMETERS = (ParameterDefinition('initial', 0.0, label='初始值', quantity='dimensionless', unit=''), ParameterDefinition('final', 1.0, label='阶跃后值', quantity='dimensionless', unit=''), ParameterDefinition('time', 0.0, label='阶跃时间', quantity='time', unit='s')) + RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),) + DISPLAY = ComponentDisplaySpec(label='STEP0 阶跃信号', library_id='amesim', category_id='signals', symbol='amesim_step0', ports=(PortDisplaySpec('out', 'right', order=10),), order=10) - MODEL_TYPE = "amesim_step0" - MODEL_VERSION = "0.1.0" - PORTS = (PortDefinition.signal("out", nominal_role="output"),) - PARAMETERS = ( - ParameterDefinition("initial", 0.0, label="初始值", quantity="dimensionless", unit=""), - ParameterDefinition("final", 1.0, label="阶跃后值", quantity="dimensionless", unit=""), - ParameterDefinition("time", 0.0, label="阶跃时间", quantity="time", unit="s"), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10), - ) - DISPLAY = ComponentDisplaySpec( - label="STEP0 阶跃信号", - library_id="amesim", - category_id="signals", - symbol="amesim_step0", - ports=(PortDisplaySpec("out", "right", order=10),), - order=10, - ) - - def __init__( - self, - name: str, - medium: IdealGasMedium, - *, - initial: float = 0.0, - final: float = 1.0, - time: float = 0.0, - ) -> None: + def __init__(self, name: str, medium: IdealGasMedium, *, initial: float=0.0, final: float=1.0, time: float=0.0) -> None: super().__init__(name=name) - self.set_parameter_values({"initial": initial, "final": final, "time": time}) + self.set_parameter_values({'initial': initial, 'final': final, 'time': time}) self.initial = float(initial) self.final = float(final) self.time = float(time) - self.out = self.register_declared_port("out") - self.out.signal = self.output_at(0.0) + self.out = self.register_declared_port('out') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> "AmesimStep0": - return cls( - name=name, - medium=medium, - initial=parameters["initial"], - final=parameters["final"], - time=parameters["time"], - ) - - def output_at(self, time: float) -> float: - return self.final if time >= self.time else self.initial - - def signal_output_values(self, time: float) -> dict[str, float]: - return {"out": self.output_at(time)} - - def signal_event_times( - self, - start_time: float, - stop_time: float, - ) -> tuple[float, ...]: - """Expose the exact STEP0 switch time as an integration split point.""" - - return (self.time,) if start_time < self.time < stop_time else () - - def component_result_values(self) -> Mapping[str, float]: - return {"y": self.out.signal} - + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimStep0': + return cls(name=name, medium=medium, initial=parameters['initial'], final=parameters['final'], time=parameters['time']) + EQUATIONS = () class AmesimUd00(AlgebraicComponent): """AMESim UD00 piecewise-linear scalar signal source.""" + MODEL_TYPE = 'amesim_ud00' + MODEL_VERSION = '0.2.0' + PORTS = (PortDefinition.signal('out', nominal_role='output'),) + PARAMETERS = (ParameterDefinition('tstart', 0.0, label='启动时间', quantity='time', unit='s', description='分段信号开始输出第一段之前的等待时间。'), *_UD00_STAGE_PARAMETERS, ParameterDefinition('nstages', 1.0, label='段数', quantity='dimensionless', unit='', minimum=1.0, maximum=8.0, editor='choice', options=tuple((ParameterOption(float(stage_count), str(stage_count)) for stage_count in range(1, 9))), description='参与输出计算的有效线性分段数量。'), ParameterDefinition('iscyclic', 0.0, label='循环', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, editor='choice', options=(ParameterOption(0.0, '否'), ParameterOption(1.0, '是')), description='当前公共协议编码:0 表示单次输出,1 表示循环输出。')) + RESULT_VARIABLES = (ResultVariableDefinition('y', '输出', 'dimensionless', '', 'signal', 10),) + DISPLAY = ComponentDisplaySpec(label='UD00 分段线性信号', library_id='amesim', category_id='signals', symbol='amesim_ud00', ports=(PortDisplaySpec('out', 'right', order=10),), order=20, parameter_groups=(ParameterGroupDisplaySpec(id='stages', label='分段参数', parameters=tuple((parameter.name for parameter in _UD00_STAGE_PARAMETERS)), order=10),)) - MODEL_TYPE = "amesim_ud00" - MODEL_VERSION = "0.2.0" - PORTS = (PortDefinition.signal("out", nominal_role="output"),) - PARAMETERS = ( - ParameterDefinition( - "tstart", - 0.0, - label="启动时间", - quantity="time", - unit="s", - description="分段信号开始输出第一段之前的等待时间。", - ), - *_UD00_STAGE_PARAMETERS, - ParameterDefinition( - "nstages", - 1.0, - label="段数", - quantity="dimensionless", - unit="", - minimum=1.0, - maximum=8.0, - editor="choice", - options=tuple( - ParameterOption(float(stage_count), str(stage_count)) - for stage_count in range(1, 9) - ), - description="参与输出计算的有效线性分段数量。", - ), - ParameterDefinition( - "iscyclic", - 0.0, - label="循环", - quantity="dimensionless", - unit="", - minimum=0.0, - maximum=1.0, - editor="choice", - options=( - ParameterOption(0.0, "否"), - ParameterOption(1.0, "是"), - ), - description="当前公共协议编码:0 表示单次输出,1 表示循环输出。", - ), - ) - RESULT_VARIABLES = ( - ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10), - ) - DISPLAY = ComponentDisplaySpec( - label="UD00 分段线性信号", - library_id="amesim", - category_id="signals", - symbol="amesim_ud00", - ports=(PortDisplaySpec("out", "right", order=10),), - order=20, - parameter_groups=( - ParameterGroupDisplaySpec( - id="stages", - label="分段参数", - parameters=tuple( - parameter.name for parameter in _UD00_STAGE_PARAMETERS - ), - order=10, - ), - ), - ) - - def __init__( - self, - name: str, - medium: IdealGasMedium, - *, - tstart: float = 0.0, - starts: tuple[float, ...] = (0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), - ends: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), - durations: tuple[float, ...] = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - nstages: int = 1, - iscyclic: bool = False, - ) -> None: + def __init__(self, name: str, medium: IdealGasMedium, *, tstart: float=0.0, starts: tuple[float, ...]=(0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), ends: tuple[float, ...]=(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), durations: tuple[float, ...]=(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), nstages: int=1, iscyclic: bool=False) -> None: super().__init__(name=name) if len(starts) != 8 or len(ends) != 8 or len(durations) != 8: - raise ValueError("UD00 requires exactly eight start, end, and duration values.") + raise ValueError('UD00 requires exactly eight start, end, and duration values.') if nstages < 1 or nstages > 8: - raise ValueError("UD00 nstages must be between 1 and 8.") + raise ValueError('UD00 nstages must be between 1 and 8.') self.tstart = float(tstart) - self.starts = tuple(float(value) for value in starts) - self.ends = tuple(float(value) for value in ends) - self.durations = tuple(float(value) for value in durations) + self.starts = tuple((float(value) for value in starts)) + self.ends = tuple((float(value) for value in ends)) + self.durations = tuple((float(value) for value in durations)) self.nstages = int(nstages) self.iscyclic = bool(iscyclic) - values: dict[str, float] = {"tstart": self.tstart, "nstages": float(self.nstages), "iscyclic": float(int(self.iscyclic))} + values: dict[str, float] = {'tstart': self.tstart, 'nstages': float(self.nstages), 'iscyclic': float(int(self.iscyclic))} for index in range(1, 9): - values[f"start{index}"] = self.starts[index - 1] - values[f"end{index}"] = self.ends[index - 1] - values[f"t{index}"] = self.durations[index - 1] + values[f'start{index}'] = self.starts[index - 1] + values[f'end{index}'] = self.ends[index - 1] + values[f't{index}'] = self.durations[index - 1] self.set_parameter_values(values) - self.out = self.register_declared_port("out") - self.out.signal = self.output_at(0.0) + self.out = self.register_declared_port('out') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> "AmesimUd00": - nstages = parameters["nstages"] - iscyclic = parameters["iscyclic"] + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> 'AmesimUd00': + nstages = parameters['nstages'] + iscyclic = parameters['iscyclic'] definitions = {definition.name: definition for definition in cls.PARAMETERS} - for parameter_name, value in ( - ("nstages", nstages), - ("iscyclic", iscyclic), - ): + for parameter_name, value in (('nstages', nstages), ('iscyclic', iscyclic)): numeric_value = float(value) if not numeric_value.is_integer(): - raise ValueError(f"UD00 {parameter_name} must be an integer.") + raise ValueError(f'UD00 {parameter_name} must be an integer.') message = definitions[parameter_name].validation_message(numeric_value) if message is not None: - raise ValueError(f"UD00 {parameter_name} {message}.") - return cls( - name=name, - medium=medium, - tstart=parameters["tstart"], - starts=tuple(parameters[f"start{index}"] for index in range(1, 9)), - ends=tuple(parameters[f"end{index}"] for index in range(1, 9)), - durations=tuple(parameters[f"t{index}"] for index in range(1, 9)), - nstages=int(nstages), - iscyclic=bool(int(iscyclic)), - ) - - def output_at(self, time: float) -> float: - elapsed = max(float(time) - self.tstart, 0.0) - active_durations = self.durations[: self.nstages] - total_duration = sum(active_durations) - if self.iscyclic and total_duration > 0.0: - elapsed = elapsed % total_duration - - stage_start_time = 0.0 - for index, duration in enumerate(active_durations): - stage_end_time = stage_start_time + duration - if elapsed < stage_end_time or index == self.nstages - 1: - if duration <= 0.0: - return self.ends[index] - fraction = (elapsed - stage_start_time) / duration - return self.starts[index] + fraction * (self.ends[index] - self.starts[index]) - stage_start_time = stage_end_time - return self.ends[self.nstages - 1] - - def signal_output_values(self, time: float) -> dict[str, float]: - return {"out": self.output_at(time)} - - def signal_event_times( - self, - start_time: float, - stop_time: float, - ) -> tuple[float, ...]: - """Return UD00 start, stage, and repeated cycle boundaries. - - The final non-cyclic stage is intentionally not given an end event: - ``output_at`` continues that stage's slope after its configured duration. - """ - - if stop_time <= start_time: - return () - - active_durations = self.durations[: self.nstages] - stage_offsets = [0.0] - elapsed = 0.0 - for duration in active_durations[:-1]: - elapsed += duration - stage_offsets.append(elapsed) - - if not self.iscyclic: - return tuple( - sorted( - { - event_time - for offset in stage_offsets - if start_time - < (event_time := self.tstart + offset) - < stop_time - } - ) - ) - - cycle_duration = sum(active_durations) - if cycle_duration <= 0.0: - return () - - events: set[float] = set() - for offset in stage_offsets: - first_boundary = self.tstart + offset - cycle_index = max( - 0, - floor((start_time - first_boundary) / cycle_duration) + 1, - ) - event_time = first_boundary + cycle_index * cycle_duration - while event_time < stop_time: - if event_time > start_time: - events.add(event_time) - cycle_index += 1 - event_time = first_boundary + cycle_index * cycle_duration - return tuple(sorted(events)) - - def component_result_values(self) -> Mapping[str, float]: - return {"y": self.out.signal} + raise ValueError(f'UD00 {parameter_name} {message}.') + return cls(name=name, medium=medium, tstart=parameters['tstart'], starts=tuple((parameters[f'start{index}'] for index in range(1, 9))), ends=tuple((parameters[f'end{index}'] for index in range(1, 9))), durations=tuple((parameters[f't{index}'] for index in range(1, 9))), nstages=int(nstages), iscyclic=bool(int(iscyclic))) + EQUATIONS = () diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py index 6bd1594..ae183ef 100644 --- a/app/simulation/components/amesim/storage/chambers.py +++ b/app/simulation/components/amesim/storage/chambers.py @@ -1,38 +1,12 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from math import isfinite - -from app.simulation.components.amesim.gases import ( - AMESIM_GAS_INDEX_PARAMETER, - normalize_amesim_gas_index, -) +from collections.abc import Mapping +from app.simulation.components.amesim.gases import AMESIM_GAS_INDEX_PARAMETER, normalize_amesim_gas_index from app.simulation.core.base import ThermodynamicVolumeComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterDefinition, - ResultVariableDefinition, - THERMODYNAMIC_VOLUME_RESULT_VARIABLES, -) -from app.simulation.core.medium import ( - GasMedium, - ThermodynamicProperties, - ThermodynamicPropertiesLinearization, -) +from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES +from app.simulation.core.medium import GasMedium from app.simulation.core.ports import PortDefinition -from app.simulation.core.state import VolumeState - - -@dataclass(frozen=True) -class Pnch012DerivativeLinearization: - derivative: tuple[float, float] - tangents: tuple[tuple[float, ...], tuple[float, ...]] - properties: ThermodynamicPropertiesLinearization - valid: bool = True - reason: str | None = None - class AmesimPnch023(ThermodynamicVolumeComponent): """AMESim PNCH023 simple pneumatic chamber with heat exchange. @@ -42,112 +16,16 @@ class AmesimPnch023(ThermodynamicVolumeComponent): framework's mass/internal-energy volume state and keeps the AMESim heat-transfer contract `kth * sth * (extemp - T)`. """ - - MODEL_TYPE = "amesim_pnch023" - MODEL_VERSION = "0.1.0" - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "cvol", - 0.057, - label="气室容积", - quantity="volume", - unit="m3", - minimum=0.0, - minimum_exclusive=True, - description="气室内部用于储存气体的固定有效容积。", - ), - ParameterDefinition( - "kth", - 0.0, - label="换热系数", - quantity="heat_transfer_coefficient", - unit="W/(m2*K)", - minimum=0.0, - description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。", - ), - ParameterDefinition( - "sth", - 0.1, - label="换热面积", - quantity="area", - unit="m2", - minimum=0.0, - description="气室与环境进行热交换的有效表面积。", - ), - ParameterDefinition( - "extemp", - 293.15, - label="外部温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。", - ), - ParameterDefinition( - "p0", - 100000.0, - label="初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - description="仿真开始时气室内气体的绝对压力。", - ), - ParameterDefinition( - "T0", - 293.15, - label="初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - description="仿真开始时气室内气体的绝对温度。", - ), - ) + MODEL_TYPE = 'amesim_pnch023' + MODEL_VERSION = '0.1.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol', 0.057, label='气室容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='气室内部用于储存气体的固定有效容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。')) RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES - DISPLAY = ComponentDisplaySpec( - label="PNCH023 固定容积气室", - library_id="amesim", - category_id="storage", - symbol="amesim_pnch023", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - ), - order=10, - ) + DISPLAY = ComponentDisplaySpec(label='PNCH023 固定容积气室', library_id='amesim', category_id='storage', symbol='amesim_pnch023', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10) - def __init__( - self, - name: str, - medium: GasMedium, - *, - cvol: float = 0.057, - kth: float = 0.0, - sth: float = 0.1, - extemp: float = 293.15, - gi: float = 1.0, - p0: float = 100000.0, - T0: float = 293.15, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, cvol: float=0.057, kth: float=0.0, sth: float=0.1, extemp: float=293.15, gi: float=1.0, p0: float=100000.0, T0: float=293.15) -> None: super().__init__(name=name) - self.set_parameter_values( - { - "cvol": cvol, - "kth": kth, - "sth": sth, - "extemp": extemp, - "gi": gi, - "p0": p0, - "T0": T0, - } - ) + self.set_parameter_values({'cvol': cvol, 'kth': kth, 'sth': sth, 'extemp': extemp, 'gi': gi, 'p0': p0, 'T0': T0}) self.medium = medium self.cvol = float(cvol) self.kth = float(kth) @@ -156,120 +34,13 @@ class AmesimPnch023(ThermodynamicVolumeComponent): self.gi = normalize_amesim_gas_index(gi) self.p0 = float(p0) self.T0 = float(T0) - m0 = medium.density(self.p0, self.T0) * self.cvol - U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0) - self.state = VolumeState(m=m0, U=U0) - initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0) - self.port_1 = self.register_declared_port("port_1") - self.port_1.p = self.p0 - self.port_1.h_outflow = initial_h - self.port_2 = self.register_declared_port("port_2") - self.port_2.p = self.p0 - self.port_2.h_outflow = initial_h + self.port_1 = self.register_declared_port('port_1') + self.port_2 = self.register_declared_port('port_2') @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> AmesimPnch023: - return cls( - name=name, - medium=medium, - cvol=parameters["cvol"], - kth=parameters["kth"], - sth=parameters["sth"], - extemp=parameters["extemp"], - gi=parameters["gi"], - p0=parameters["p0"], - T0=parameters["T0"], - ) - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.cvol) - self.port_1.p = props.p - self.port_1.h_outflow = props.h - self.port_2.p = props.p - self.port_2.h_outflow = props.h - return props - - def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: - return self.properties() - - def thermal_energy_flow_w(self, temperature: float) -> float: - return self.kth * self.sth * (self.extemp - temperature) - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - props = self.properties() - inlet_h_1 = self.connection_inlet_enthalpy( - port_m_flow=self.port_1.m_flow, - connected_h=connected_h["port_1"], - internal_h=props.h, - ) - inlet_h_2 = self.connection_inlet_enthalpy( - port_m_flow=self.port_2.m_flow, - connected_h=connected_h["port_2"], - internal_h=props.h, - ) - derivative = VolumeState( - m=self.port_1.m_flow + self.port_2.m_flow, - U=( - self.port_1.m_flow * inlet_h_1 - + self.port_2.m_flow * inlet_h_2 - + self.thermal_energy_flow_w(props.T) - ), - ) - return derivative.as_vector() - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - pressure = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.cvol, - ).p - return ( - self.port_1.p - pressure, - self.port_2.p - pressure, - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - pressure = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.cvol, - ).p - return ( - EquationResidual( - id=f"{self.name}:port_1_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_1.p", f"{self.name}.state"), - role="effort", - value=self.port_1.p - pressure, - ), - EquationResidual( - id=f"{self.name}:port_2_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_2.p", f"{self.name}.state"), - role="effort", - value=self.port_2.p - pressure, - ), - ) - + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> AmesimPnch023: + return cls(name=name, medium=medium, cvol=parameters['cvol'], kth=parameters['kth'], sth=parameters['sth'], extemp=parameters['extemp'], gi=parameters['gi'], p0=parameters['p0'], T0=parameters['T0']) + EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}) class AmesimPnch012(ThermodynamicVolumeComponent): """AMESim PNCH012 variable-volume pneumatic chamber. @@ -279,143 +50,16 @@ class AmesimPnch012(ThermodynamicVolumeComponent): parameters, while connected moving-boundary components can now add live volume and volume-rate values through the pneumatic connector contract. """ + MODEL_TYPE = 'amesim_pnch012' + MODEL_VERSION = '0.1.0' + PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional')) + PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s')) + RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110)) + DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20) - MODEL_TYPE = "amesim_pnch012" - MODEL_VERSION = "0.1.0" - PORTS = ( - PortDefinition.pneumatic("port_1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_2", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_3", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_4", nominal_role="bidirectional"), - ) - PARAMETERS = ( - AMESIM_GAS_INDEX_PARAMETER, - ParameterDefinition( - "cvol0", - 0.015, - label="死容积", - quantity="volume", - unit="m3", - minimum=0.0, - minimum_exclusive=True, - description="变容气室在所有外部容积为零时仍保留的基础容积。", - ), - ParameterDefinition( - "kth", - 0.0, - label="换热系数", - quantity="heat_transfer_coefficient", - unit="W/(m2*K)", - minimum=0.0, - description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。", - ), - ParameterDefinition( - "sth", - 0.1, - label="换热面积", - quantity="area", - unit="m2", - minimum=0.0, - description="气室与环境进行热交换的有效表面积。", - ), - ParameterDefinition( - "extemp", - 293.15, - label="外部温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。", - ), - ParameterDefinition( - "p0", - 100000.0, - label="初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - description="仿真开始时气室内气体的绝对压力。", - ), - ParameterDefinition( - "T0", - 293.15, - label="初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - description="仿真开始时气室内气体的绝对温度。", - ), - ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"), - ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"), - ParameterDefinition("vol3", 0.0, label="端口 3 外部容积", quantity="volume", unit="m3"), - ParameterDefinition("vol4", 0.0, label="端口 4 外部容积", quantity="volume", unit="m3"), - ParameterDefinition("dvol1", 0.0, label="端口 1 容积变化率", quantity="volume_flow", unit="m3/s"), - ParameterDefinition("dvol2", 0.0, label="端口 2 容积变化率", quantity="volume_flow", unit="m3/s"), - ParameterDefinition("dvol3", 0.0, label="端口 3 容积变化率", quantity="volume_flow", unit="m3/s"), - ParameterDefinition("dvol4", 0.0, label="端口 4 容积变化率", quantity="volume_flow", unit="m3/s"), - ) - RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + ( - ResultVariableDefinition("vol", "气室总容积", "volume", "m3", "derived", 100), - ResultVariableDefinition("dvol", "总容积变化率", "volume_flow", "m3/s", "derived", 110), - ) - DISPLAY = ComponentDisplaySpec( - label="PNCH012 变容气室", - library_id="amesim", - category_id="storage", - symbol="amesim_pnch012", - ports=( - PortDisplaySpec("port_1", "left", order=10), - PortDisplaySpec("port_2", "right", order=20), - PortDisplaySpec("port_3", "left", order=30), - PortDisplaySpec("port_4", "right", order=40), - ), - order=20, - ) - - def __init__( - self, - name: str, - medium: GasMedium, - *, - cvol0: float = 0.015, - kth: float = 0.0, - sth: float = 0.1, - extemp: float = 293.15, - gi: float = 1.0, - p0: float = 100000.0, - T0: float = 293.15, - vol1: float = 0.0, - vol2: float = 0.0, - vol3: float = 0.0, - vol4: float = 0.0, - dvol1: float = 0.0, - dvol2: float = 0.0, - dvol3: float = 0.0, - dvol4: float = 0.0, - ) -> None: + def __init__(self, name: str, medium: GasMedium, *, cvol0: float=0.015, kth: float=0.0, sth: float=0.1, extemp: float=293.15, gi: float=1.0, p0: float=100000.0, T0: float=293.15, vol1: float=0.0, vol2: float=0.0, vol3: float=0.0, vol4: float=0.0, dvol1: float=0.0, dvol2: float=0.0, dvol3: float=0.0, dvol4: float=0.0) -> None: super().__init__(name=name) - self.set_parameter_values( - { - "cvol0": cvol0, - "kth": kth, - "sth": sth, - "extemp": extemp, - "gi": gi, - "p0": p0, - "T0": T0, - "vol1": vol1, - "vol2": vol2, - "vol3": vol3, - "vol4": vol4, - "dvol1": dvol1, - "dvol2": dvol2, - "dvol3": dvol3, - "dvol4": dvol4, - } - ) + self.set_parameter_values({'cvol0': cvol0, 'kth': kth, 'sth': sth, 'extemp': extemp, 'gi': gi, 'p0': p0, 'T0': T0, 'vol1': vol1, 'vol2': vol2, 'vol3': vol3, 'vol4': vol4, 'dvol1': dvol1, 'dvol2': dvol2, 'dvol3': dvol3, 'dvol4': dvol4}) self.medium = medium self.cvol0 = float(cvol0) self.kth = float(kth) @@ -424,267 +68,13 @@ class AmesimPnch012(ThermodynamicVolumeComponent): self.gi = normalize_amesim_gas_index(gi) self.p0 = float(p0) self.T0 = float(T0) - self.external_volumes = { - "port_1": float(vol1), - "port_2": float(vol2), - "port_3": float(vol3), - "port_4": float(vol4), - } - self.external_volume_rates = { - "port_1": float(dvol1), - "port_2": float(dvol2), - "port_3": float(dvol3), - "port_4": float(dvol4), - } - if self.total_volume() <= 0.0: - raise ValueError("PNCH012 total volume must be positive.") - m0 = medium.density(self.p0, self.T0) * self.total_volume() - U0 = m0 * medium.specific_internal_energy_at_pressure(self.p0, self.T0) - self.state = VolumeState(m=m0, U=U0) - initial_h = medium.specific_enthalpy_at_pressure(self.p0, self.T0) - for port_name in ("port_1", "port_2", "port_3", "port_4"): + self.external_volumes = {'port_1': float(vol1), 'port_2': float(vol2), 'port_3': float(vol3), 'port_4': float(vol4)} + self.external_volume_rates = {'port_1': float(dvol1), 'port_2': float(dvol2), 'port_3': float(dvol3), 'port_4': float(dvol4)} + for port_name in ('port_1', 'port_2', 'port_3', 'port_4'): port = self.register_declared_port(port_name) - port.p = self.p0 - port.h_outflow = initial_h setattr(self, port_name, port) @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> "AmesimPnch012": + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> 'AmesimPnch012': return cls(name=name, medium=medium, **dict(parameters)) - - def connected_external_volume(self) -> float: - return sum( - getattr(getattr(self, port_name, None), "volume", 0.0) - for port_name in self.external_volumes - ) - - def connected_external_volume_rate(self) -> float: - return sum( - getattr(getattr(self, port_name, None), "volume_flow", 0.0) - for port_name in self.external_volume_rates - ) - - def total_volume(self) -> float: - minimum_volume = self.cvol0 / 100.0 - return max( - self.cvol0 + sum(self.external_volumes.values()) + self.connected_external_volume(), - minimum_volume, - ) - - def total_volume_rate(self) -> float: - if self.total_volume() <= self.cvol0 / 100.0: - return 0.0 - return sum(self.external_volume_rates.values()) + self.connected_external_volume_rate() - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.total_volume()) - for port_name in ("port_1", "port_2", "port_3", "port_4"): - port = self.get_port(port_name) - port.p = props.p - port.h_outflow = props.h - return props - - def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: - return self.properties() - - def thermal_energy_flow_w(self, temperature: float) -> float: - return self.kth * self.sth * (self.extemp - temperature) - - def component_result_values(self) -> Mapping[str, float]: - props = self.properties() - return { - "m": self.state.m, - "U": self.state.U, - "p": props.p, - "T": props.T, - "rho": props.rho, - "u": props.u, - "h": props.h, - "vol": self.total_volume(), - "dvol": self.total_volume_rate(), - } - - def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]: - props = self.properties() - mass_derivative = 0.0 - energy_derivative = 0.0 - for port_name in ("port_1", "port_2", "port_3", "port_4"): - port = self.get_port(port_name) - inlet_h = self.connection_inlet_enthalpy( - port_m_flow=port.m_flow, - connected_h=connected_h[port_name], - internal_h=props.h, - ) - mass_derivative += port.m_flow - energy_derivative += port.m_flow * inlet_h - energy_derivative += self.thermal_energy_flow_w(props.T) - energy_derivative -= props.p * self.total_volume_rate() - return VolumeState(m=mass_derivative, U=energy_derivative).as_vector() - - def linearize_state_derivative( - self, - connected_h: Mapping[str, float], - *, - state_mass_tangent: Sequence[float], - state_energy_tangent: Sequence[float], - external_volume_tangent: Sequence[float], - external_volume_rate_tangent: Sequence[float], - port_mass_flow_tangents: Mapping[str, Sequence[float]], - connected_h_tangents: Mapping[str, Sequence[float]], - property_linearization: ThermodynamicPropertiesLinearization | None = None, - flow_boundary_tolerance: float = 1.0e-12, - ) -> Pnch012DerivativeLinearization: - """Linearize the chamber balance while keeping stream modes fixed.""" - - port_names = ("port_1", "port_2", "port_3", "port_4") - vectors = { - "state_mass": tuple(float(value) for value in state_mass_tangent), - "state_energy": tuple(float(value) for value in state_energy_tangent), - "volume": tuple(float(value) for value in external_volume_tangent), - "volume_rate": tuple( - float(value) for value in external_volume_rate_tangent - ), - } - for port_name in port_names: - vectors[f"flow:{port_name}"] = tuple( - float(value) for value in port_mass_flow_tangents[port_name] - ) - vectors[f"enthalpy:{port_name}"] = tuple( - float(value) for value in connected_h_tangents[port_name] - ) - widths = {len(values) for values in vectors.values()} - if len(widths) != 1: - raise ValueError("PNCH012 tangent vectors must have equal lengths.") - width = len(vectors["state_mass"]) - invalid_reason: str | None = None - if not all(isfinite(value) for values in vectors.values() for value in values): - invalid_reason = "non_finite_tangent_input" - - raw_volume = ( - self.cvol0 - + sum(self.external_volumes.values()) - + self.connected_external_volume() - ) - minimum_volume = self.cvol0 / 100.0 - volume_scale = max(abs(raw_volume), abs(minimum_volume), 1.0e-18) - on_volume_boundary = ( - abs(raw_volume - minimum_volume) <= 1.0e-12 * volume_scale - ) - supplied_volume_tangent = vectors["volume"] - if raw_volume < minimum_volume or on_volume_boundary: - used_volume_tangent = (0.0,) * width - used_volume_rate_tangent = (0.0,) * width - if on_volume_boundary and any( - value != 0.0 - for value in ( - *supplied_volume_tangent, - *vectors["volume_rate"], - ) - ): - invalid_reason = invalid_reason or "volume_floor_boundary" - else: - used_volume_tangent = supplied_volume_tangent - used_volume_rate_tangent = vectors["volume_rate"] - - properties = property_linearization or self.medium.linearize_properties_from_mU( - self.state.m, - self.state.U, - self.total_volume(), - vectors["state_mass"], - vectors["state_energy"], - used_volume_tangent, - ) - if properties.tangents.width != width: - raise ValueError( - "PNCH012 property tangent width must match balance tangents." - ) - props = properties.properties - if not properties.valid: - invalid_reason = invalid_reason or properties.reason - - mass_derivative = sum( - self.get_port(port_name).m_flow for port_name in port_names - ) - volume_rate = self.total_volume_rate() - energy_derivative = self.thermal_energy_flow_w(props.T) - props.p * volume_rate - mass_tangent = [0.0] * width - energy_tangent = [ - -self.kth * self.sth * properties.tangents.T[index] - - volume_rate * properties.tangents.p[index] - - props.p * used_volume_rate_tangent[index] - for index in range(width) - ] - - for port_name in port_names: - port = self.get_port(port_name) - flow_tangent = vectors[f"flow:{port_name}"] - if ( - abs(port.m_flow) <= flow_boundary_tolerance - and any(value != 0.0 for value in flow_tangent) - ): - invalid_reason = invalid_reason or ( - f"flow_direction_boundary:{port_name}" - ) - if port.m_flow > 0.0: - inlet_h = connected_h[port_name] - inlet_h_tangent = vectors[f"enthalpy:{port_name}"] - else: - inlet_h = props.h - inlet_h_tangent = properties.tangents.h - energy_derivative += port.m_flow * inlet_h - for index in range(width): - mass_tangent[index] += flow_tangent[index] - energy_tangent[index] += ( - inlet_h * flow_tangent[index] - + port.m_flow * inlet_h_tangent[index] - ) - - return Pnch012DerivativeLinearization( - derivative=(mass_derivative, energy_derivative), - tangents=(tuple(mass_tangent), tuple(energy_tangent)), - properties=properties, - valid=invalid_reason is None, - reason=invalid_reason, - ) - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - pressure = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.total_volume(), - ).p - return tuple( - self.get_port(port_name).p - pressure - for port_name in ("port_1", "port_2", "port_3", "port_4") - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - pressure = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.total_volume(), - ).p - return tuple( - EquationResidual( - id=f"{self.name}:{port_name}_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.{port_name}.p", f"{self.name}.state"), - role="effort", - value=self.get_port(port_name).p - pressure, - ) - for port_name in ("port_1", "port_2", "port_3", "port_4") - ) + EQUATIONS = ({'id': '__MODEL__:port_1_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_1.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_2_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_2.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_3_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_3.p', '__MODEL__.state'], 'role': 'effort'}, {'id': '__MODEL__:port_4_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_4.p', '__MODEL__.state'], 'role': 'effort'}) diff --git a/app/simulation/components/example.md b/app/simulation/components/example.md index d9ad7aa..49b2165 100644 --- a/app/simulation/components/example.md +++ b/app/simulation/components/example.md @@ -1,282 +1,14 @@ -# 元件建模规范与示例 +# 元件开发示例 -规范的权威版本位于 -[`docs/standard/component-model-authoring-spec-v1.md`](../../../docs/standard/component-model-authoring-spec-v1.md)。 -本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次 -修改中同步,不能让示例形成另一套规则。 +权威规则见 [组件模型建模规范](../../../docs/standard/component-model-authoring-spec-v1.md)。当前模型采用 Python 声明、C 数值实现。 -本文档是 `app/simulation/components` 下新增元件的最小开发规范。当前 -`experimental` 是用于验证规范的临时组件库;后续正式模型应建立独立组件库, -不要继续堆放在 `experimental` 中。 +以气瓶为例: -目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML -校验、求解器和前端分别维护同一份含义。 +1. 在 [cylinder.py](experimental/storage/cylinder.py) 声明 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、`DISPLAY` 和 `create()`。 +2. 构造函数调用 `set_parameter_values()`、`register_declared_port()`,保存介质选择和容积。不要在 Python 中计算密度、内能或状态导数。 +3. 通过 `EQUATIONS` 声明端口压力与气瓶状态之间的约束;只保存变量名和关系。 +4. 在 [extended.py](../native_codegen/extended.py) 分配状态及输出位置,生成 `native_medium_init()` 初始化调用和气瓶质量/能量导数计算。 +5. 公共物性和数值公式由 [kernels.c](../../../native/components/kernels.c) 实现,积分和事件由 `native/runtime/` 处理。 +6. 加入组件库 `library.py` 及 C 版本白名单,验证目录/XML 合同、边界输入、逆流、守恒、RK45/BDF 和输出键。 -## 一、元件类必须声明的内容 - -每个对外注册的元件类至少需要声明以下六个类属性: - -```python -MODEL_TYPE = "example_component" -MODEL_VERSION = "1.0.0" -PORTS = (...) -PARAMETERS = (...) -RESULT_VARIABLES = (...) -DISPLAY = ... -``` - -- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。 -- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。 -- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。 -- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。 -- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。 -- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。 - -元件构造函数还必须: - -1. 调用 `super().__init__(name)`。 -2. 使用 `set_parameter_values()` 保存规范化后的输入参数。 -3. 使用 `register_declared_port()` 创建已声明端口。 -4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。 -5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。 - -## 二、输入参数与结果变量 - -输入参数和仿真结果必须分开声明: - -- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。 -- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。 -- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。 -- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。 - -参数定义示例: - -```python -ParameterDefinition( - name="volume", - label="容积", - quantity="volume", - unit="m3", - default=0.1, - minimum=0.0, - minimum_exclusive=True, -) -``` - -结果变量定义示例: - -```python -ResultVariableDefinition( - name="p", - label="压力", - quantity="pressure", - unit="Pa", - category="thermodynamic", - order=30, -) -``` - -## 三、命名和单位约定 - -- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。 -- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。 -- 无量纲参数的 `unit` 使用空字符串。 -- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。 -- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。 -- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。 - -## 四、完整示例:单端口储气容腔 - -下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。 - -```python -from __future__ import annotations - -from collections.abc import Mapping - -from app.simulation.core.base import ThermodynamicVolumeComponent -from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterDefinition, - THERMODYNAMIC_VOLUME_RESULT_VARIABLES, -) -from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties -from app.simulation.core.ports import PortDefinition -from app.simulation.core.state import VolumeState - - -class ExampleVolume(ThermodynamicVolumeComponent): - MODEL_TYPE = "example_volume" - MODEL_VERSION = "1.0.0" - PORTS = ( - PortDefinition.pneumatic("port_a", nominal_role="bidirectional"), - ) - PARAMETERS = ( - ParameterDefinition( - name="volume", - label="容积", - quantity="volume", - unit="m3", - default=0.1, - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - name="p0", - label="初始压力", - quantity="pressure", - unit="Pa", - default=100000.0, - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - name="T0", - label="初始温度", - quantity="temperature", - unit="K", - default=300.0, - minimum=0.0, - minimum_exclusive=True, - ), - ) - RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES - DISPLAY = ComponentDisplaySpec( - label="示例容腔", - library_id="experimental", - category_id="storage", - symbol="generic", - ports=(PortDisplaySpec("port_a", "left"),), - order=90, - ) - - def __init__( - self, - name: str, - medium: IdealGasMedium, - volume: float = 0.1, - p0: float = 100000.0, - T0: float = 300.0, - ) -> None: - super().__init__(name) - self.set_parameter_values( - {"volume": volume, "p0": p0, "T0": T0} - ) - self.medium = medium - self.V = volume - initial_mass = p0 * volume / (medium.R_gas * T0) - initial_energy = initial_mass * medium.specific_internal_energy(T0) - self.state = VolumeState(m=initial_mass, U=initial_energy) - self.port_a = self.register_declared_port("port_a") - - @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> ExampleVolume: - return cls( - name=name, - medium=medium, - volume=parameters["volume"], - p0=parameters["p0"], - T0=parameters["T0"], - ) - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: - properties = self.medium.properties_from_mU( - self.state.m, self.state.U, self.V - ) - self.port_a.p = properties.p - self.port_a.h_outflow = properties.h - return properties - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - properties = self.refresh_thermodynamic_ports() - inlet_h = self.connection_inlet_enthalpy( - port_m_flow=self.port_a.m_flow, - connected_h=connected_h["port_a"], - internal_h=properties.h, - ) - return [self.port_a.m_flow, self.port_a.m_flow * inlet_h] - - def pressure_flow_equation_residuals( - self, - ) -> tuple[EquationResidual, ...]: - pressure = self.medium.properties_from_mU( - self.state.m, self.state.U, self.V - ).p - return ( - EquationResidual( - id=f"{self.name}:port_a_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_a.p", f"{self.name}.state"), - role="effort", - value=self.port_a.p - pressure, - ), - ) -``` - -模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库 -`library.py` 的 `models` 清单: - -```python -models=( - # ...已有模型 - "app.simulation.components.experimental.storage.example_volume:ExampleVolume", -) -``` - -后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例, -再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件, -前端刷新时即可加载。 -当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把 -`library_id` 指向正式库。 - -完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义: - -```json -{ - "key": "example_volume_1.port_a.m_flow", - "componentId": "example_volume_1", - "componentType": "example_volume", - "scope": "port", - "portName": "port_a", - "name": "m_flow", - "label": "质量流量", - "quantity": "mass_flow", - "unit": "kg/s", - "category": "flow", - "order": 20 -} -``` - -## 五、新增元件检查清单 - -1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。 -2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。 -3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。 -4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。 -5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。 -6. 是否只暴露有工程意义的结果,而非内部计算变量。 -7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。 -8. 是否实现统一的 `create()`,并能用默认参数创建模型。 -9. 模型类路径是否只加入所属库的 `library.py` 清单。 -10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。 - -组件库、分类和自动发现的完整规则参见 -[`组件库分类、发现与读取规范 v1`](../../../docs/standard/component-library-spec-v1.md)。 +新增模型的参考值应来自独立解析结果、外部可信结果或已有冻结基准;不恢复第二套 Python 数值实现。 diff --git a/app/simulation/components/experimental/flow/orifice.py b/app/simulation/components/experimental/flow/orifice.py index eb2ee8a..68ac73a 100644 --- a/app/simulation/components/experimental/flow/orifice.py +++ b/app/simulation/components/experimental/flow/orifice.py @@ -1,121 +1,34 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping -from math import sqrt - from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual from app.simulation.core.metadata import ParameterDefinition from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition - class Orifice(AlgebraicComponent): """Python port of ModelicaModels.Myorifice.""" - - MODEL_TYPE = "orifice" - MODEL_VERSION = "1.0.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_a", nominal_role="inlet"), - PortDefinition.pneumatic("port_b", nominal_role="outlet"), - ) - PARAMETERS = ( - ParameterDefinition( - "K", - 1e-5, - label="流量系数", - quantity="flow_coefficient", - unit="kg/(s*Pa^0.5)", - minimum=0.0, - ), - ParameterDefinition( - "opening", - 1.0, - label="开度", - minimum=0.0, - maximum=1.0, - ), - ) + MODEL_TYPE = 'orifice' + MODEL_VERSION = '1.0.0' + PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'), PortDefinition.pneumatic('port_b', nominal_role='outlet')) + PARAMETERS = (ParameterDefinition('K', 1e-05, label='流量系数', quantity='flow_coefficient', unit='kg/(s*Pa^0.5)', minimum=0.0), ParameterDefinition('opening', 1.0, label='开度', minimum=0.0, maximum=1.0)) RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="孔板/阀门", - library_id="experimental", - category_id="flow", - symbol="orifice", - ports=( - PortDisplaySpec("port_a", "left", order=10), - PortDisplaySpec("port_b", "right", order=20), - ), - order=40, - ) + DISPLAY = ComponentDisplaySpec(label='孔板/阀门', library_id='experimental', category_id='flow', symbol='orifice', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=40) - def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None: + def __init__(self, name: str, opening: float=1.0, K: float=1e-05) -> None: super().__init__(name=name) - self.set_parameter_values({"K": K, "opening": opening}) + self.set_parameter_values({'K': K, 'opening': opening}) self.opening = opening self.K = K - self.port_a = self.register_declared_port("port_a") - self.port_b = self.register_declared_port("port_b") + self.port_a = self.register_declared_port('port_a') + self.port_b = self.register_declared_port('port_b') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> Orifice: - return cls( - name=name, - opening=parameters["opening"], - K=parameters["K"], - ) + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Orifice: + return cls(name=name, opening=parameters['opening'], K=parameters['K']) @property def K_eff(self) -> float: return self.K * max(self.opening, 0.001) - - def mass_flow(self, p_a: float, p_b: float) -> float: - dp = p_a - p_b - if dp == 0.0: - return 0.0 - return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=( - f"{self.name}.port_a.m_flow", - f"{self.name}.port_b.m_flow", - ), - role="flow", - value=self.port_a.m_flow + self.port_b.m_flow, - ), - EquationResidual( - id=f"{self.name}:pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_a.p", - f"{self.name}.port_b.p", - f"{self.name}.port_a.m_flow", - ), - role="flow", - value=self.port_a.m_flow - - self.mass_flow(self.port_a.p, self.port_b.p), - ), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self.port_a.h_outflow = connected_h["port_b"] - self.port_b.h_outflow = connected_h["port_a"] + EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_a.m_flow', '__MODEL__.port_b.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:pressure_flow_relation', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_a.p', '__MODEL__.port_b.p', '__MODEL__.port_a.m_flow'], 'role': 'flow'}) diff --git a/app/simulation/components/experimental/flow/pipe.py b/app/simulation/components/experimental/flow/pipe.py deleted file mode 100644 index 005deb0..0000000 --- a/app/simulation/components/experimental/flow/pipe.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Compatibility import for the TestModel-only dynamic pipe. - -The public ``pipe`` catalog model is ``ResistivePipe``. New code should import -this legacy dynamic model from ``app.simulation.examples.testmodel.dynamic_pipe``. -""" - -from app.simulation.examples.testmodel.dynamic_pipe import Pipe - - -__all__ = ("Pipe",) diff --git a/app/simulation/components/experimental/flow/resistive_pipe.py b/app/simulation/components/experimental/flow/resistive_pipe.py index 40abfac..73fc08d 100644 --- a/app/simulation/components/experimental/flow/resistive_pipe.py +++ b/app/simulation/components/experimental/flow/resistive_pipe.py @@ -1,106 +1,25 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping from math import pi - from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual from app.simulation.core.metadata import ParameterDefinition from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition - class ResistivePipe(AlgebraicComponent): """Quasi-steady Darcy resistance used by topology-driven simulation.""" - - MODEL_TYPE = "pipe" - MODEL_VERSION = "1.0.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_a", nominal_role="inlet"), - PortDefinition.pneumatic("port_b", nominal_role="outlet"), - ) - PARAMETERS = ( - ParameterDefinition( - "length", - 5.0, - label="长度", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "diameter", - 0.02, - label="直径", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "lambda_darcy", - 0.02, - label="摩阻系数", - minimum=0.0, - ), - ParameterDefinition( - "p0", - 1e5, - label="初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "T0", - 300.0, - label="初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - ), - ) + MODEL_TYPE = 'pipe' + MODEL_VERSION = '1.0.0' + PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'), PortDefinition.pneumatic('port_b', nominal_role='outlet')) + PARAMETERS = (ParameterDefinition('length', 5.0, label='长度', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('diameter', 0.02, label='直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True), ParameterDefinition('lambda_darcy', 0.02, label='摩阻系数', minimum=0.0), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="管段", - library_id="experimental", - category_id="flow", - symbol="pipe", - ports=( - PortDisplaySpec("port_a", "left", order=10), - PortDisplaySpec("port_b", "right", order=20), - ), - order=30, - ) + DISPLAY = ComponentDisplaySpec(label='管段', library_id='experimental', category_id='flow', symbol='pipe', ports=(PortDisplaySpec('port_a', 'left', order=10), PortDisplaySpec('port_b', 'right', order=20)), order=30) - def __init__( - self, - name: str, - medium: IdealGasMedium, - L: float = 5.0, - D: float = 0.02, - lambda_darcy: float = 0.02, - p0: float = 1e5, - T0: float = 300.0, - ) -> None: + def __init__(self, name: str, medium: IdealGasMedium, L: float=5.0, D: float=0.02, lambda_darcy: float=0.02, p0: float=100000.0, T0: float=300.0) -> None: super().__init__(name=name) - self.set_parameter_values( - { - "length": L, - "diameter": D, - "lambda_darcy": lambda_darcy, - "p0": p0, - "T0": T0, - } - ) + self.set_parameter_values({'length': L, 'diameter': D, 'lambda_darcy': lambda_darcy, 'p0': p0, 'T0': T0}) self.medium = medium self.L = L self.D = D @@ -108,82 +27,10 @@ class ResistivePipe(AlgebraicComponent): self.p0 = p0 self.T0 = T0 self.area = pi * D * D / 4.0 - initial_h = medium.specific_enthalpy(T0) - - self.port_a = self.register_declared_port("port_a") - self.port_a.p = p0 - self.port_a.h_outflow = initial_h - - self.port_b = self.register_declared_port("port_b") - self.port_b.p = p0 - self.port_b.h_outflow = initial_h + self.port_a = self.register_declared_port('port_a') + self.port_b = self.register_declared_port('port_b') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> ResistivePipe: - return cls( - name=name, - medium=medium, - L=parameters["length"], - D=parameters["diameter"], - lambda_darcy=parameters["lambda_darcy"], - p0=parameters["p0"], - T0=parameters["T0"], - ) - - def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float: - average_pressure = max(0.5 * (p_a + p_b), 1.0) - density = max(self.medium.density(average_pressure, self.T0), 1e-12) - resistance = self.lambda_darcy * (self.L / self.D) - return ( - resistance - * m_flow_a - * abs(m_flow_a) - / (2.0 * density * self.area * self.area) - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=( - f"{self.name}.port_a.m_flow", - f"{self.name}.port_b.m_flow", - ), - role="flow", - value=self.port_a.m_flow + self.port_b.m_flow, - ), - EquationResidual( - id=f"{self.name}:darcy_pressure_loss", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_a.p", - f"{self.name}.port_b.p", - f"{self.name}.port_a.m_flow", - ), - role="effort", - value=( - self.port_a.p - - self.port_b.p - - self.pressure_drop( - self.port_a.m_flow, - self.port_a.p, - self.port_b.p, - ) - ), - ), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self.port_a.h_outflow = connected_h["port_b"] - self.port_b.h_outflow = connected_h["port_a"] + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> ResistivePipe: + return cls(name=name, medium=medium, L=parameters['length'], D=parameters['diameter'], lambda_darcy=parameters['lambda_darcy'], p0=parameters['p0'], T0=parameters['T0']) + EQUATIONS = ({'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_a.m_flow', '__MODEL__.port_b.m_flow'], 'role': 'flow'}, {'id': '__MODEL__:darcy_pressure_loss', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'constitutive', 'variables': ['__MODEL__.port_a.p', '__MODEL__.port_b.p', '__MODEL__.port_a.m_flow'], 'role': 'effort'}) diff --git a/app/simulation/components/experimental/junctions/tee.py b/app/simulation/components/experimental/junctions/tee.py index b50a642..85695c4 100644 --- a/app/simulation/components/experimental/junctions/tee.py +++ b/app/simulation/components/experimental/junctions/tee.py @@ -1,270 +1,28 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping - from app.simulation.core.base import AlgebraicComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition - class Tee(AlgebraicComponent): """Python port of ModelicaModels.Mytee.""" - - MODEL_TYPE = "tee" - MODEL_VERSION = "1.0.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES = frozenset( - ("mass_flow_balance",) - ) - PORTS = ( - PortDefinition.pneumatic("port_in", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"), - ) + MODEL_TYPE = 'tee' + MODEL_VERSION = '1.0.0' + PORTS = (PortDefinition.pneumatic('port_in', nominal_role='bidirectional'), PortDefinition.pneumatic('port_out1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_out2', nominal_role='bidirectional')) PARAMETERS = () RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="三通", - library_id="experimental", - category_id="junctions", - symbol="tee", - ports=( - PortDisplaySpec("port_in", "left", order=10), - PortDisplaySpec("port_out1", "right", order=20), - PortDisplaySpec("port_out2", "right", order=30), - ), - order=50, - ) + DISPLAY = ComponentDisplaySpec(label='三通', library_id='experimental', category_id='junctions', symbol='tee', ports=(PortDisplaySpec('port_in', 'left', order=10), PortDisplaySpec('port_out1', 'right', order=20), PortDisplaySpec('port_out2', 'right', order=30)), order=50) def __init__(self, name: str) -> None: super().__init__(name=name) self.set_parameter_values({}) - self.port_in = self.register_declared_port("port_in") - self.port_out1 = self.register_declared_port("port_out1") - self.port_out2 = self.register_declared_port("port_out2") + self.port_in = self.register_declared_port('port_in') + self.port_out1 = self.register_declared_port('port_out1') + self.port_out2 = self.register_declared_port('port_out2') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> Tee: + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tee: return cls(name=name) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:common_pressure_out1", - owner="component", - owner_id=self.name, - relation="equal", - variables=(f"{self.name}.port_in.p", f"{self.name}.port_out1.p"), - role="effort", - value=self.port_in.p - self.port_out1.p, - ), - EquationResidual( - id=f"{self.name}:common_pressure_out2", - owner="component", - owner_id=self.name, - relation="equal", - variables=(f"{self.name}.port_in.p", f"{self.name}.port_out2.p"), - role="effort", - value=self.port_in.p - self.port_out2.p, - ), - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=( - f"{self.name}.port_in.m_flow", - f"{self.name}.port_out1.m_flow", - f"{self.name}.port_out2.m_flow", - ), - role="flow", - value=( - self.port_in.m_flow - + self.port_out1.m_flow - + self.port_out2.m_flow - ), - ), - ) - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - incoming = [ - (port.m_flow, connected_h[name]) - for name, port in self.ports.items() - if port.m_flow > 1e-12 - ] - total_flow = sum(m_flow for m_flow, _ in incoming) - if total_flow > 1e-12: - mixed_h = sum( - m_flow * enthalpy for m_flow, enthalpy in incoming - ) / total_flow - else: - values = list(connected_h.values()) - mixed_h = sum(values) / len(values) if values else 0.0 - for port in self.ports.values(): - port.h_outflow = mixed_h - - def mixed_inlet_enthalpy( - self, - branch1_m_flow: float, - branch1_h: float, - branch2_m_flow: float, - branch2_h: float, - fallback_h: float = 0.0, - ) -> float: - positive_1 = max(branch1_m_flow, 0.0) - positive_2 = max(branch2_m_flow, 0.0) - total = positive_1 + positive_2 - if total <= 1e-9: - return fallback_h - return (positive_1 * branch1_h + positive_2 * branch2_h) / total - - def inlet_stream_enthalpy( - self, - branch1_m_flow: float, - branch1_h: float, - branch2_m_flow: float, - branch2_h: float, - fallback_h: float, - ) -> float: - """Approximate `inStream(port_in.h_outflow)` for the current tee topology.""" - - return self.mixed_inlet_enthalpy( - branch1_m_flow, - branch1_h, - branch2_m_flow, - branch2_h, - fallback_h=fallback_h, - ) - - def branch_actual_stream_enthalpy( - self, - branch_m_flow: float, - branch_h: float, - inlet_h: float, - ) -> float: - """Approximate `actualStream(branch.h_outflow)` for a tee branch port.""" - - return inlet_h if branch_m_flow > 0.0 else branch_h - - @staticmethod - def _solve_linear_2x2( - a11: float, - a12: float, - a21: float, - a22: float, - b1: float, - b2: float, - ) -> tuple[float, float] | None: - determinant = a11 * a22 - a12 * a21 - if abs(determinant) <= 1e-12: - return None - x1 = (b1 * a22 - b2 * a12) / determinant - x2 = (a11 * b2 - a21 * b1) / determinant - return x1, x2 - - def solve_branch_outlet_flows_from_energy_balance( - self, - *, - ratio_branch1: float, - ratio_branch2: float, - inlet_h_branch1: float, - inlet_h_branch2: float, - branch1_h: float, - branch2_h: float, - inlet_h: float, - q_in_branch1: float, - q_in_branch2: float, - tolerance: float = 1e-12, - ) -> tuple[float, float]: - """Solve branch outlet flows for the current three-port downstream tee use-case.""" - - rhs_branch1 = q_in_branch1 * inlet_h_branch1 - rhs_branch2 = q_in_branch2 * inlet_h_branch2 - - def solve_both_forward() -> tuple[float, float] | None: - return self._solve_linear_2x2( - (1.0 + ratio_branch1) * branch1_h, - ratio_branch1 * branch2_h, - ratio_branch2 * branch1_h, - (1.0 + ratio_branch2) * branch2_h, - rhs_branch1, - rhs_branch2, - ) - - def solve_one_reverse( - *, - branch1_reverse: bool, - ) -> tuple[float, float] | None: - if branch1_reverse: - return self._solve_linear_2x2( - inlet_h * (1.0 + ratio_branch1), - ratio_branch1 * inlet_h, - ratio_branch2 * inlet_h, - branch2_h + ratio_branch2 * inlet_h, - rhs_branch1, - rhs_branch2, - ) - - return self._solve_linear_2x2( - branch1_h + ratio_branch1 * inlet_h, - ratio_branch1 * inlet_h, - ratio_branch2 * inlet_h, - inlet_h * (1.0 + ratio_branch2), - rhs_branch1, - rhs_branch2, - ) - - def solve_both_reverse() -> tuple[float, float] | None: - return self._solve_linear_2x2( - inlet_h * (1.0 + ratio_branch1), - ratio_branch1 * inlet_h, - ratio_branch2 * inlet_h, - inlet_h * (1.0 + ratio_branch2), - rhs_branch1, - rhs_branch2, - ) - - candidate_solvers = ( - ( - solve_both_forward, - lambda q1, q2: q1 >= -tolerance and q2 >= -tolerance, - ), - ( - lambda: solve_one_reverse(branch1_reverse=True), - lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 > tolerance, - ), - ( - lambda: solve_one_reverse(branch1_reverse=True), - lambda q1, q2: q1 < -tolerance and q2 >= -tolerance and q1 + q2 <= tolerance, - ), - ( - lambda: solve_one_reverse(branch1_reverse=False), - lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 > tolerance, - ), - ( - lambda: solve_one_reverse(branch1_reverse=False), - lambda q1, q2: q2 < -tolerance and q1 >= -tolerance and q1 + q2 <= tolerance, - ), - ( - solve_both_reverse, - lambda q1, q2: q1 < -tolerance and q2 < -tolerance, - ), - ) - - for solver, predicate in candidate_solvers: - candidate = solver() - if candidate is None: - continue - q_out_branch1, q_out_branch2 = candidate - if predicate(q_out_branch1, q_out_branch2): - return q_out_branch1, q_out_branch2 - - return solve_both_forward() or (0.0, 0.0) + EQUATIONS = ({'id': '__MODEL__:common_pressure_out1', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_in.p', '__MODEL__.port_out1.p'], 'role': 'effort'}, {'id': '__MODEL__:common_pressure_out2', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'equal', 'variables': ['__MODEL__.port_in.p', '__MODEL__.port_out2.p'], 'role': 'effort'}, {'id': '__MODEL__:mass_flow_balance', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'sumToZero', 'variables': ['__MODEL__.port_in.m_flow', '__MODEL__.port_out1.m_flow', '__MODEL__.port_out2.m_flow'], 'role': 'flow'}) diff --git a/app/simulation/components/experimental/storage/cylinder.py b/app/simulation/components/experimental/storage/cylinder.py index ce72f85..789a5d5 100644 --- a/app/simulation/components/experimental/storage/cylinder.py +++ b/app/simulation/components/experimental/storage/cylinder.py @@ -1,155 +1,29 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping - from app.simulation.core.base import ThermodynamicVolumeComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterDefinition, - THERMODYNAMIC_VOLUME_RESULT_VARIABLES, -) -from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES +from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition -from app.simulation.core.state import VolumeState - class Cylinder(ThermodynamicVolumeComponent): """Python port of ModelicaModels.Mycylinder.""" - - MODEL_TYPE = "cylinder" - MODEL_VERSION = "1.0.0" - PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),) - PARAMETERS = ( - ParameterDefinition( - "volume", - 0.01, - label="容积", - quantity="volume", - unit="m3", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "p0", - 35e6, - label="初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "T0", - 300.0, - label="初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - ), - ) + MODEL_TYPE = 'cylinder' + MODEL_VERSION = '1.0.0' + PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet'),) + PARAMETERS = (ParameterDefinition('volume', 0.01, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 35000000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES - DISPLAY = ComponentDisplaySpec( - label="气瓶", - library_id="experimental", - category_id="storage", - symbol="cylinder", - ports=(PortDisplaySpec("port_b", "right"),), - order=10, - ) + DISPLAY = ComponentDisplaySpec(label='气瓶', library_id='experimental', category_id='storage', symbol='cylinder', ports=(PortDisplaySpec('port_b', 'right'),), order=10) - def __init__( - self, - name: str, - medium: IdealGasMedium, - V: float = 0.01, - p0: float = 35e6, - T0: float = 300.0, - ) -> None: + def __init__(self, name: str, medium: IdealGasMedium, V: float=0.01, p0: float=35000000.0, T0: float=300.0) -> None: super().__init__(name=name) - self.set_parameter_values({"volume": V, "p0": p0, "T0": T0}) + self.set_parameter_values({'volume': V, 'p0': p0, 'T0': T0}) self.medium = medium self.V = V - m0 = p0 * V / (medium.R_gas * T0) - U0 = m0 * medium.specific_internal_energy(T0) - self.state = VolumeState(m=m0, U=U0) - self.port_b = self.register_declared_port("port_b") + self.port_b = self.register_declared_port('port_b') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> Cylinder: - return cls( - name=name, - medium=medium, - V=parameters["volume"], - p0=parameters["p0"], - T0=parameters["T0"], - ) - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) - self.port_b.p = props.p - self.port_b.h_outflow = props.h - return props - - def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: - return self.properties() - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - properties = self.properties() - derivative = self.derivatives_from_connection( - connected_h=connected_h["port_b"], - port_m_flow=self.port_b.m_flow, - internal_h=properties.h, - ) - return derivative.as_vector() - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - pressure = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.V, - ).p - return ( - EquationResidual( - id=f"{self.name}:port_b_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_b.p", f"{self.name}.state"), - role="effort", - value=self.port_b.p - pressure, - ), - ) - - def derivatives_from_connection( - self, - *, - connected_h: float, - port_m_flow: float, - internal_h: float, - ) -> VolumeState: - inlet_h = self.connection_inlet_enthalpy( - port_m_flow=port_m_flow, - connected_h=connected_h, - internal_h=internal_h, - ) - return self.derivatives(inlet_h, port_m_flow) - - def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: - return VolumeState(m=m_flow, U=m_flow * inlet_h) + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Cylinder: + return cls(name=name, medium=medium, V=parameters['volume'], p0=parameters['p0'], T0=parameters['T0']) + EQUATIONS = ({'id': '__MODEL__:port_b_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_b.p', '__MODEL__.state'], 'role': 'effort'},) diff --git a/app/simulation/components/experimental/storage/tank.py b/app/simulation/components/experimental/storage/tank.py index 6f3f336..e5e5f1c 100644 --- a/app/simulation/components/experimental/storage/tank.py +++ b/app/simulation/components/experimental/storage/tank.py @@ -1,155 +1,29 @@ +"""Component parameters, ports and output definitions; numerical equations execute in C.""" from __future__ import annotations - from collections.abc import Mapping - from app.simulation.core.base import ThermodynamicVolumeComponent from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterDefinition, - THERMODYNAMIC_VOLUME_RESULT_VARIABLES, -) -from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties +from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES +from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition -from app.simulation.core.state import VolumeState - class Tank(ThermodynamicVolumeComponent): """Python port of ModelicaModels.Mytank.""" - - MODEL_TYPE = "tank" - MODEL_VERSION = "1.0.0" - PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),) - PARAMETERS = ( - ParameterDefinition( - "volume", - 0.1, - label="容积", - quantity="volume", - unit="m3", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "p0", - 1e5, - label="初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "T0", - 300.0, - label="初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - ), - ) + MODEL_TYPE = 'tank' + MODEL_VERSION = '1.0.0' + PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'),) + PARAMETERS = (ParameterDefinition('volume', 0.1, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T0', 300.0, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True)) RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES - DISPLAY = ComponentDisplaySpec( - label="贮箱", - library_id="experimental", - category_id="storage", - symbol="tank", - ports=(PortDisplaySpec("port_a", "left"),), - order=20, - ) + DISPLAY = ComponentDisplaySpec(label='贮箱', library_id='experimental', category_id='storage', symbol='tank', ports=(PortDisplaySpec('port_a', 'left'),), order=20) - def __init__( - self, - name: str, - medium: IdealGasMedium, - V: float = 0.1, - p0: float = 1e5, - T0: float = 300.0, - ) -> None: + def __init__(self, name: str, medium: IdealGasMedium, V: float=0.1, p0: float=100000.0, T0: float=300.0) -> None: super().__init__(name=name) - self.set_parameter_values({"volume": V, "p0": p0, "T0": T0}) + self.set_parameter_values({'volume': V, 'p0': p0, 'T0': T0}) self.medium = medium self.V = V - m0 = p0 * V / (medium.R_gas * T0) - U0 = m0 * medium.specific_internal_energy(T0) - self.state = VolumeState(m=m0, U=U0) - self.port_a = self.register_declared_port("port_a") + self.port_a = self.register_declared_port('port_a') @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> Tank: - return cls( - name=name, - medium=medium, - V=parameters["volume"], - p0=parameters["p0"], - T0=parameters["T0"], - ) - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) - self.port_a.p = props.p - self.port_a.h_outflow = props.h - return props - - def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: - return self.properties() - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - properties = self.properties() - derivative = self.derivatives_from_connection( - connected_h=connected_h["port_a"], - port_m_flow=self.port_a.m_flow, - internal_h=properties.h, - ) - return derivative.as_vector() - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - pressure = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.V, - ).p - return ( - EquationResidual( - id=f"{self.name}:port_a_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_a.p", f"{self.name}.state"), - role="effort", - value=self.port_a.p - pressure, - ), - ) - - def derivatives_from_connection( - self, - *, - connected_h: float, - port_m_flow: float, - internal_h: float, - ) -> VolumeState: - inlet_h = self.connection_inlet_enthalpy( - port_m_flow=port_m_flow, - connected_h=connected_h, - internal_h=internal_h, - ) - return self.derivatives(inlet_h, port_m_flow) - - def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: - return VolumeState(m=m_flow, U=m_flow * inlet_h) + def create(cls, *, name: str, medium: IdealGasMedium, parameters: Mapping[str, float]) -> Tank: + return cls(name=name, medium=medium, V=parameters['volume'], p0=parameters['p0'], T0=parameters['T0']) + EQUATIONS = ({'id': '__MODEL__:port_a_pressure_state', 'owner': 'component', 'ownerId': '__MODEL__', 'relation': 'state', 'variables': ['__MODEL__.port_a.p', '__MODEL__.state'], 'role': 'effort'},) diff --git a/app/simulation/config.py b/app/simulation/config.py new file mode 100644 index 0000000..daa9b2d --- /dev/null +++ b/app/simulation/config.py @@ -0,0 +1,148 @@ +"""Simulation options and progress data; no numerical solver implementation.""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Sequence + +@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. + The tracker receives aggregate counters from the independent C worker. + """ + + __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_native_progress(self, time: float, rhs_count: int, accepted_count: int) -> None: + """Publish aggregate counters from an isolated C worker without per-RHS callbacks.""" + self._rhs_call_count = max(self._rhs_call_count, rhs_count) + self._accepted_step_sequence = max(self._accepted_step_sequence, accepted_count) + self._accepted_time = max(self._accepted_time or time, time) + self._publish("native_solver", time) + + def record_jacobian(self, time: float) -> None: + self._jacobian_evaluation_count += 1 + self._publish("jacobian", time) + + def record_thermofluid_closure(self, time: float) -> None: + self._thermofluid_closure_count += 1 + self._publish("thermofluid_closure", time) + + def record_accepted_step(self, time: float) -> None: + accepted_time = float(time) + if ( + self._accepted_time is not None + and accepted_time <= self._accepted_time + ): + return + self._accepted_step_sequence += 1 + self._accepted_time = accepted_time + self._publish("accepted_step", accepted_time) + + def snapshot(self) -> SolverActivitySnapshot: + # ``activity_sequence`` is read last because writers publish it last. + activity_kind = self._activity_kind + current_trial_time = self._current_trial_time + rhs_call_count = self._rhs_call_count + accepted_step_sequence = self._accepted_step_sequence + accepted_time = self._accepted_time + solver_step_sequence = self._solver_step_sequence + jacobian_evaluation_count = self._jacobian_evaluation_count + thermofluid_closure_count = self._thermofluid_closure_count + activity_sequence = self._activity_sequence + return SolverActivitySnapshot( + activity_sequence=activity_sequence, + activity_kind=activity_kind, + current_trial_time=current_trial_time, + rhs_call_count=rhs_call_count, + accepted_step_sequence=accepted_step_sequence, + accepted_time=accepted_time, + solver_step_sequence=solver_step_sequence, + jacobian_evaluation_count=jacobian_evaluation_count, + thermofluid_closure_count=thermofluid_closure_count, + ) + +@dataclass(frozen=True) +class SolveIVPConfig: + t_start: float = 0.0 + t_stop: float = 20.0 + method: str = "BDF" + rtol: float = 1e-6 + atol: float | Sequence[float] = 1e-8 + max_step: float = 1e-3 + first_step: float | None = None diff --git a/app/simulation/core/base.py b/app/simulation/core/base.py index 6200e53..540c58c 100644 --- a/app/simulation/core/base.py +++ b/app/simulation/core/base.py @@ -1,40 +1,17 @@ from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Callable, Mapping -from typing import TYPE_CHECKING, Any, ClassVar - +from abc import ABC +from collections.abc import Mapping +from typing import TYPE_CHECKING, ClassVar from app.simulation.core.catalog import ComponentDisplaySpec -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterDefinition, - ResultVariableDefinition, - ResultVariableMetadata, - THERMODYNAMIC_VOLUME_RESULT_VARIABLES, -) +from app.simulation.core.equations import EquationDefinition +from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, ResultVariableMetadata, THERMODYNAMIC_VOLUME_RESULT_VARIABLES from app.simulation.core.ports import PortDefinition, PortState - if TYPE_CHECKING: from app.simulation.core.medium import GasMedium - class Component(ABC): MODEL_TYPE: ClassVar[str | None] = None MODEL_VERSION: ClassVar[str | None] = None - # ``True`` means that pressure/flow residuals read values written by - # ``update_stream_outflows`` or ``update_flow_temperature_references``. - # ``False`` is an explicit promise that those residuals are independent of - # stream propagation. ``None`` keeps custom components conservative: when - # they override either stream hook, the closure planner retains the legacy - # full-network thermofluid fixed point. - PRESSURE_FLOW_DEPENDS_ON_STREAM: ClassVar[bool | None] = None - # Exact residual suffixes whose declared variables are summed, in order, - # to form a ``sumToZero`` flow equation. The causal solver deliberately - # reads this capability from the concrete class ``__dict__``: subclasses - # must repeat the promise after changing any equation semantics. - PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES: ClassVar[ - frozenset[str] - ] = frozenset() PORTS: ClassVar[tuple[PortDefinition, ...]] = () PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = () RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = () @@ -52,80 +29,53 @@ class Component(ABC): @property def port_definitions(self) -> tuple[PortDefinition, ...]: - return tuple( - port.definition - for port in self._ports.values() - if port.definition is not None - ) + return tuple((port.definition for port in self._ports.values() if port.definition is not None)) @classmethod - def active_port_definitions_for_parameters( - cls, - parameters: Mapping[str, float], - ) -> tuple[PortDefinition, ...]: + def active_port_definitions_for_parameters(cls, parameters: Mapping[str, float]) -> tuple[PortDefinition, ...]: """Declared ports enabled by one normalized parameter set.""" - return cls.PORTS @property def active_port_definitions(self) -> tuple[PortDefinition, ...]: """Instance ports that participate in execution and result reporting.""" - return self.port_definitions @property def required_connection_ports(self) -> tuple[str, ...]: """Physical ports that must have an external connection before simulation.""" - - return tuple( - definition.name - for definition in self.active_port_definitions - if definition.kind == "physical" - ) + return tuple((definition.name for definition in self.active_port_definitions if definition.kind == 'physical')) def register_port(self, port: PortState) -> PortState: definition = port.definition if definition is None: - raise ValueError(f"Component {self.name} cannot register an undefined port.") + raise ValueError(f'Component {self.name} cannot register an undefined port.') if definition.name in self._ports: - raise ValueError(f"Duplicate port {self.name}.{definition.name}.") + raise ValueError(f'Duplicate port {self.name}.{definition.name}.') self._ports[definition.name] = port return port def register_declared_port(self, name: str) -> PortState: try: - definition = next(item for item in self.PORTS if item.name == name) + definition = next((item for item in self.PORTS if item.name == name)) except StopIteration as exc: - raise ValueError( - f"Component model {self.model_type} does not declare port {name}." - ) from exc + raise ValueError(f'Component model {self.model_type} does not declare port {name}.') from exc return self.register_port(PortState(definition=definition)) def set_parameter_values(self, values: Mapping[str, float]) -> None: definitions = {definition.name: definition for definition in self.PARAMETERS} unknown = sorted(set(values) - set(definitions)) if unknown: - raise ValueError( - f"Component {self.name} contains unsupported parameters: " - + ", ".join(unknown) - + "." - ) + raise ValueError(f'Component {self.name} contains unsupported parameters: ' + ', '.join(unknown) + '.') missing = sorted(set(definitions) - set(values)) if missing: - raise ValueError( - f"Component {self.name} is missing parameters: " - + ", ".join(missing) - + "." - ) - + raise ValueError(f'Component {self.name} is missing parameters: ' + ', '.join(missing) + '.') resolved: dict[str, float] = {} for name, definition in definitions.items(): value = float(values[name]) message = definition.validation_message(value) if message is not None: - raise ValueError( - f"Parameter '{name}' on component '{self.name}' {message}." - ) + raise ValueError(f"Parameter '{name}' on component '{self.name}' {message}.") resolved[name] = value self._parameter_values = resolved @@ -137,229 +87,40 @@ class Component(ABC): try: return self._ports[name] except KeyError as exc: - raise ValueError(f"Component {self.name} has no port named {name}.") from exc - - def component_result_values(self) -> Mapping[str, float]: - return {} - - def result_values(self) -> dict[str, float]: - component_values = dict(self.component_result_values()) - declared = {definition.name: definition for definition in self.RESULT_VARIABLES} - unknown = sorted(set(component_values) - set(declared)) - if unknown: - raise ValueError( - f"Component {self.name} returned undeclared result variables: " - + ", ".join(unknown) - + "." - ) - - values: dict[str, float] = {} - for name, definition in declared.items(): - if not definition.visible: - continue - if name not in component_values: - raise ValueError( - f"Component {self.name} did not provide declared result variable {name}." - ) - values[name] = float(component_values[name]) - - for port_definition in self.active_port_definitions: - port = self.get_port(port_definition.name) - for variable in port_definition.variables: - if not variable.result_visible: - continue - values[f"{port_definition.name}.{variable.name}"] = float( - getattr(port, variable.name) - ) - return values + raise ValueError(f'Component {self.name} has no port named {name}.') from exc def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]: - metadata = [ - ResultVariableMetadata( - key=f"{self.name}.{definition.name}", - component_id=self.name, - component_type=self.model_type, - scope="component", - name=definition.name, - label=definition.label, - quantity=definition.quantity, - unit=definition.unit, - category=definition.category, - order=definition.order, - ) - for definition in self.RESULT_VARIABLES - if definition.visible - ] + metadata = [ResultVariableMetadata(key=f'{self.name}.{definition.name}', component_id=self.name, component_type=self.model_type, scope='component', name=definition.name, label=definition.label, quantity=definition.quantity, unit=definition.unit, category=definition.category, order=definition.order) for definition in self.RESULT_VARIABLES if definition.visible] for port_definition in self.active_port_definitions: for variable in port_definition.variables: if not variable.result_visible: continue - metadata.append( - ResultVariableMetadata( - key=f"{self.name}.{port_definition.name}.{variable.name}", - component_id=self.name, - component_type=self.model_type, - scope="port", - port_name=port_definition.name, - name=variable.name, - label=variable.label or variable.name, - quantity=variable.quantity or variable.name, - unit=variable.unit, - category=variable.role, - order=variable.order, - ) - ) + metadata.append(ResultVariableMetadata(key=f'{self.name}.{port_definition.name}.{variable.name}', component_id=self.name, component_type=self.model_type, scope='port', port_name=port_definition.name, name=variable.name, label=variable.label or variable.name, quantity=variable.quantity or variable.name, unit=variable.unit, category=variable.role, order=variable.order)) return tuple(metadata) def parameter_interface_dicts(self) -> list[dict[str, object]]: - return [ - definition.as_interface_dict( - value=self._parameter_values.get(definition.name) - ) - for definition in self.PARAMETERS - ] + return [definition.as_interface_dict(value=self._parameter_values.get(definition.name)) for definition in self.PARAMETERS] @classmethod - def create( - cls, - *, - name: str, - medium: GasMedium, - parameters: Mapping[str, float], - ) -> Component: + def create(cls, *, name: str, medium: GasMedium, parameters: Mapping[str, float]) -> Component: """Create a catalog model from normalized SI parameters.""" + raise NotImplementedError(f'Component model {cls.__name__} must implement create().') + EQUATIONS = () - raise NotImplementedError( - f"Component model {cls.__name__} must implement create()." - ) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - """Return algebraic residuals after the network assigns port states.""" - - return () - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - """Return live residual values in the declared equation order. - - Components with frequently evaluated equations can override this - method to avoid rebuilding immutable equation metadata during closure. - The default keeps third-party components compatible with the public - residual API. - """ - - return tuple( - float(equation.value) - for equation in self.pressure_flow_equation_residuals() - ) - - def pressure_flow_equation_value_readers( - self, - ) -> Mapping[str, Callable[[], float]]: - """Return explicitly separable scalar residual readers. - - The solver consumes this optional capability only when the concrete - component class declares the method itself. Subclasses therefore - cannot accidentally inherit an equation-purity promise. - """ - - return {} - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - """Update connector outflow properties from current flow directions.""" - - return None - - def update_flow_temperature_references( - self, - connected_h: Mapping[str, float], - ) -> None: - """Update enthalpy references used only by pressure-flow laws. - - Most components use the normal stream enthalpy for both energy - transport and upstream-property evaluation. AMESim node submodels can - expose a distinct temperature reference, so the default is a no-op. - """ - - return None - - def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]: - """Return directed ``volume``/``volume_flow`` values by pneumatic port. - - Most pneumatic components contribute no external chamber volume. Moving - boundaries such as PNRP17 override this hook; the network resolver then - propagates the pair to the component connected at the same physical port. - """ - - return {} + def equation_definitions(self): + def bind(value): + if isinstance(value, str): + return value.replace('__MODEL__', self.name) + return tuple((bind(v) for v in value)) + return tuple((EquationDefinition(id=bind(e['id']), owner=e['owner'], owner_id=self.name, relation=e['relation'], variables=bind(e['variables']), role=e['role']) for e in self.EQUATIONS)) class DynamicComponent(Component): state_size = 2 - @staticmethod - def actual_stream_enthalpy( - port_m_flow: float, - connected_h: float, - internal_h: float, - ) -> float: - """Approximate `actualStream(port.h_outflow)` for a mixed control volume port.""" - - return connected_h if port_m_flow > 0.0 else internal_h - - def connection_inlet_enthalpy( - self, - port_m_flow: float, - connected_h: float, - internal_h: float, - ) -> float: - """Resolve the enthalpy convected into this control volume through one port.""" - - return self.actual_stream_enthalpy( - port_m_flow=port_m_flow, - connected_h=connected_h, - internal_h=internal_h, - ) - - @abstractmethod - def get_state_vector(self) -> list[float]: - raise NotImplementedError - - @abstractmethod - def set_state_vector(self, values: list[float]) -> None: - raise NotImplementedError - - def refresh_thermodynamic_ports(self) -> Any: - raise NotImplementedError - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - raise NotImplementedError - - class ThermodynamicVolumeComponent(DynamicComponent): """Two-state gas volume exposing the shared thermodynamic result contract.""" - RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES - def component_result_values(self) -> Mapping[str, float]: - state = self.get_state_vector() - if len(state) < 2: - raise ValueError( - f"Thermodynamic component {self.name} must expose mass and energy states." - ) - properties = self.refresh_thermodynamic_ports() - return { - "m": float(state[0]), - "U": float(state[1]), - "p": float(properties.p), - "T": float(properties.T), - "rho": float(properties.rho), - "u": float(properties.u), - "h": float(properties.h), - } - - class AlgebraicComponent(Component): """Stateless element described by algebraic constraints only.""" diff --git a/app/simulation/core/equations.py b/app/simulation/core/equations.py index ece76f1..330609d 100644 --- a/app/simulation/core/equations.py +++ b/app/simulation/core/equations.py @@ -11,15 +11,14 @@ EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"] @dataclass(frozen=True, slots=True) -class EquationResidual: - """One executable scalar equation in the pressure-flow subsystem.""" +class EquationDefinition: + """One declarative equation in the compiled model interface.""" id: str owner: EquationOwner owner_id: str relation: EquationRelation variables: tuple[str, ...] - value: float role: VariableRole | None = None def as_definition_dict(self) -> dict[str, object]: @@ -33,4 +32,4 @@ class EquationResidual: } def as_interface_dict(self) -> dict[str, object]: - return {**self.as_definition_dict(), "residual": self.value} + return self.as_definition_dict() diff --git a/app/simulation/core/medium.py b/app/simulation/core/medium.py index fd6da1f..af6e7a5 100644 --- a/app/simulation/core/medium.py +++ b/app/simulation/core/medium.py @@ -1,378 +1,16 @@ +"""Compile-time gas property constants. No Python property evaluator.""" from __future__ import annotations - from dataclasses import dataclass -from math import isfinite -from typing import Protocol, Sequence - -from app.simulation.core.errors import RecoverableTrialStateError -from app.simulation.performance import profile_property - - -@dataclass(frozen=True) -class ThermodynamicProperties: - p: float - T: float - rho: float - u: float - h: float - - -@dataclass(frozen=True) -class ThermodynamicPropertyTangents: - """Directional derivatives of a recovered thermodynamic state.""" - - p: tuple[float, ...] - T: tuple[float, ...] - rho: tuple[float, ...] - u: tuple[float, ...] - h: tuple[float, ...] - - @property - def width(self) -> int: - return len(self.p) - - @classmethod - def zeros(cls, width: int) -> "ThermodynamicPropertyTangents": - values = (0.0,) * width - return cls(p=values, T=values, rho=values, u=values, h=values) - - -@dataclass(frozen=True) -class ThermodynamicPropertiesLinearization: - """Primal properties and a validity-checked directional linearization.""" - - properties: ThermodynamicProperties - tangents: ThermodynamicPropertyTangents - valid: bool = True - reason: str | None = None - - -class GasMedium(Protocol): - """Thermodynamic contract required by pneumatic components. - - ``IdealGasMedium`` is the default implementation. Keeping the component - boundary structural allows a later helium/Peng-Robinson implementation to - be registered without changing every AMESim component constructor. - """ - - name: str - R_gas: float - cp_ref: float - T_ref: float - - @property - def cv(self) -> float: ... - - @property - def gamma(self) -> float: ... - - def cp_at_temperature(self, T: float) -> float: ... - - def cv_at_temperature(self, T: float) -> float: ... - - def density(self, p: float, T: float) -> float: ... - - def isentropic_density_pressure_factor( - self, - p: float, - T: float, - downstream_pressure: float | None = None, - ) -> float: ... - - def dynamic_viscosity(self, T: float) -> float: ... - - def diagnostic_dynamic_viscosity(self, T: float) -> float: ... - - def specific_internal_energy(self, T: float) -> float: ... - - def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: ... - - def specific_enthalpy(self, T: float) -> float: ... - - def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: ... - - def temperature_from_internal_energy(self, u: float) -> float: ... - - def temperature_from_enthalpy(self, h: float) -> float: ... - - def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: ... - - def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: ... - - def pressure(self, m: float, T: float, V: float) -> float: ... - - def properties_from_mU( - self, - m: float, - U: float, - V: float, - ) -> ThermodynamicProperties: ... - - def linearize_properties_from_mU( - self, - m: float, - U: float, - V: float, - dm: Sequence[float], - dU: Sequence[float], - dV: Sequence[float], - *, - properties: ThermodynamicProperties | None = None, - ) -> ThermodynamicPropertiesLinearization: ... - @dataclass(frozen=True) class IdealGasMedium: - """Temperature-dependent ideal-gas air approximation. - - This is still not a strict clone of `Modelica.Media.Air.SimpleAir`. - The small linear `cp(T)` term is kept configurable for calibration, but the - current default is calibrated against the committed Testmodel baseline and - therefore falls back to the constant-heat-capacity limit. - """ - - name: str = "SimpleAirApprox" + name: str = 'SimpleAirApprox' R_gas: float = 287.0 cp_ref: float = 1005.0 T_ref: float = 300.0 cp_slope: float = 0.0 - viscosity_ref: float = 1.82e-5 + viscosity_ref: float = 1.82e-05 viscosity_T_ref: float = 293.15 sutherland_constant: float = 110.4 - @property - def cv(self) -> float: - return self.cv_at_temperature(self.T_ref) - - @property - def gamma(self) -> float: - return self.cp_at_temperature(self.T_ref) / self.cv - - def cp_at_temperature(self, T: float) -> float: - return self.cp_ref + self.cp_slope * (T - self.T_ref) - - def cv_at_temperature(self, T: float) -> float: - return self.cp_at_temperature(T) - self.R_gas - - @profile_property("density") - def density(self, p: float, T: float) -> float: - return p / (self.R_gas * T) - - @profile_property("isentropic_density_pressure_factor") - def isentropic_density_pressure_factor( - self, - p: float, - T: float, - downstream_pressure: float | None = None, - ) -> float: - del p - del downstream_pressure - cp = self.cp_at_temperature(T) - cv = self.cv_at_temperature(T) - return cv / cp - - @profile_property("dynamic_viscosity") - def dynamic_viscosity(self, T: float) -> float: - """Return dynamic viscosity using the default air Sutherland law.""" - - if T <= 0.0: - raise ValueError("Temperature must be positive.") - return ( - self.viscosity_ref - * (T / self.viscosity_T_ref) ** 1.5 - * (self.viscosity_T_ref + self.sutherland_constant) - / (T + self.sutherland_constant) - ) - - def diagnostic_dynamic_viscosity(self, T: float) -> float: - """Return the viscosity convention used by derived diagnostics. - - Most media use the same transport property for dynamics and reported - diagnostics. Reference-library media may override this without - changing a calibrated constitutive flow relation. - """ - - return self.dynamic_viscosity(T) - - @profile_property("specific_internal_energy") - def specific_internal_energy(self, T: float) -> float: - delta_T = T - self.T_ref - return ( - self.cv * self.T_ref - + self.cv * delta_T - + 0.5 * self.cp_slope * delta_T * delta_T - ) - - @profile_property("specific_internal_energy_at_pressure") - def specific_internal_energy_at_pressure(self, p: float, T: float) -> float: - del p - return self.specific_internal_energy(T) - - @profile_property("specific_enthalpy") - def specific_enthalpy(self, T: float) -> float: - delta_T = T - self.T_ref - return ( - self.cp_ref * self.T_ref - + self.cp_ref * delta_T - + 0.5 * self.cp_slope * delta_T * delta_T - ) - - @profile_property("specific_enthalpy_at_pressure") - def specific_enthalpy_at_pressure(self, p: float, T: float) -> float: - del p - return self.specific_enthalpy(T) - - def temperature_from_internal_energy(self, u: float) -> float: - reference_internal_energy = self.cv * self.T_ref - delta_u = u - reference_internal_energy - - if abs(self.cp_slope) <= 1e-15: - return self.T_ref + delta_u / self.cv - - a = 0.5 * self.cp_slope - b = self.cv - c = -delta_u - discriminant = max(b * b - 4.0 * a * c, 0.0) - positive_root = (-b + discriminant**0.5) / (2.0 * a) - negative_root = (-b - discriminant**0.5) / (2.0 * a) - delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root - return self.T_ref + delta_T - - def temperature_from_enthalpy(self, h: float) -> float: - reference_enthalpy = self.cp_ref * self.T_ref - delta_h = h - reference_enthalpy - - if abs(self.cp_slope) <= 1e-15: - return self.T_ref + delta_h / self.cp_ref - - a = 0.5 * self.cp_slope - b = self.cp_ref - c = -delta_h - discriminant = max(b * b - 4.0 * a * c, 0.0) - positive_root = (-b + discriminant**0.5) / (2.0 * a) - negative_root = (-b - discriminant**0.5) / (2.0 * a) - delta_T = positive_root if abs(positive_root) <= abs(negative_root) else negative_root - return self.T_ref + delta_T - - @profile_property("temperature_from_pressure_enthalpy") - def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float: - del p - return self.temperature_from_enthalpy(h) - - def temperature_from_mass_internal_energy(self, m: float, U: float) -> float: - if m <= 0.0: - raise RecoverableTrialStateError( - "Mass must stay positive when recovering temperature." - ) - return self.temperature_from_internal_energy(U / m) - - def pressure(self, m: float, T: float, V: float) -> float: - if V <= 0.0: - raise ValueError("Volume must stay positive.") - return m * self.R_gas * T / V - - @profile_property("properties_from_mU") - def properties_from_mU(self, m: float, U: float, V: float) -> ThermodynamicProperties: - T = self.temperature_from_mass_internal_energy(m, U) - p = self.pressure(m, T, V) - rho = m / V - u = U / m - h = self.specific_enthalpy(T) - return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h) - - def linearize_properties_from_mU( - self, - m: float, - U: float, - V: float, - dm: Sequence[float], - dU: Sequence[float], - dV: Sequence[float], - *, - properties: ThermodynamicProperties | None = None, - ) -> ThermodynamicPropertiesLinearization: - """Linearize properties_from_mU for several seed directions.""" - - dm_values = tuple(float(value) for value in dm) - dU_values = tuple(float(value) for value in dU) - dV_values = tuple(float(value) for value in dV) - if not (len(dm_values) == len(dU_values) == len(dV_values)): - raise ValueError("Thermodynamic tangent vectors must have equal lengths.") - props = properties or self.properties_from_mU(m, U, V) - width = len(dm_values) - expected_density = m / V - expected_internal_energy = U / m - if ( - abs(props.rho - expected_density) - > 1.0e-12 * max(abs(expected_density), 1.0) - or abs(props.u - expected_internal_energy) - > 1.0e-12 * max(abs(expected_internal_energy), 1.0) - ): - return ThermodynamicPropertiesLinearization( - properties=props, - tangents=ThermodynamicPropertyTangents.zeros(width), - valid=False, - reason="properties_primal_mismatch", - ) - if not all( - isfinite(value) - for values in (dm_values, dU_values, dV_values) - for value in values - ): - return ThermodynamicPropertiesLinearization( - properties=props, - tangents=ThermodynamicPropertyTangents.zeros(width), - valid=False, - reason="non_finite_tangent_input", - ) - - cv = self.cv_at_temperature(props.T) - cp = self.cp_at_temperature(props.T) - if not isfinite(cv) or not isfinite(cp) or cv <= 0.0 or cp <= 0.0: - return ThermodynamicPropertiesLinearization( - properties=props, - tangents=ThermodynamicPropertyTangents.zeros(width), - valid=False, - reason="non_positive_heat_capacity", - ) - - drho: list[float] = [] - du: list[float] = [] - dT: list[float] = [] - dp: list[float] = [] - dh: list[float] = [] - for mass_tangent, energy_tangent, volume_tangent in zip( - dm_values, - dU_values, - dV_values, - strict=True, - ): - density_tangent = mass_tangent / V - m * volume_tangent / (V * V) - internal_energy_tangent = ( - energy_tangent / m - U * mass_tangent / (m * m) - ) - temperature_tangent = internal_energy_tangent / cv - pressure_tangent = self.R_gas * ( - props.T * density_tangent + props.rho * temperature_tangent - ) - enthalpy_tangent = cp * temperature_tangent - drho.append(density_tangent) - du.append(internal_energy_tangent) - dT.append(temperature_tangent) - dp.append(pressure_tangent) - dh.append(enthalpy_tangent) - - tangent_values = (*drho, *du, *dT, *dp, *dh) - valid = all(isfinite(value) for value in tangent_values) - return ThermodynamicPropertiesLinearization( - properties=props, - tangents=ThermodynamicPropertyTangents( - p=tuple(dp), - T=tuple(dT), - rho=tuple(drho), - u=tuple(du), - h=tuple(dh), - ), - valid=valid, - reason=None if valid else "non_finite_property_tangent", - ) +GasMedium = IdealGasMedium diff --git a/app/simulation/core/peng_robinson.py b/app/simulation/core/peng_robinson.py deleted file mode 100644 index 6e61996..0000000 --- a/app/simulation/core/peng_robinson.py +++ /dev/null @@ -1,424 +0,0 @@ -from __future__ import annotations - -from app.simulation.core.errors import RecoverableTrialStateError - -from dataclasses import dataclass -from math import acos, cos, isfinite, log, pi, sqrt - -from app.simulation.performance import profile_property - -UNIVERSAL_GAS_CONSTANT = 8.31446261815324 -# Simcenter Amesim 2404 ``sag_reinit_eos_`` keeps more digits than the -# commonly printed Peng-Robinson constants 0.45724 and 0.07780. -PENG_ROBINSON_A_COEFFICIENT = 0.457235583 -PENG_ROBINSON_B_COEFFICIENT = 0.07779607 - - -@dataclass(frozen=True) -class PengRobinsonFluid: - """Pure-fluid Peng-Robinson equation-of-state helper. - - The class covers the equation-of-state layer plus the enthalpy departure - needed to compare AMESim pneumatic ``pn2hpti`` reference enthalpy flows. - """ - - name: str - molar_mass: float - critical_temperature: float - critical_pressure: float - acentric_factor: float - - @property - def specific_gas_constant(self) -> float: - return UNIVERSAL_GAS_CONSTANT / self.molar_mass - - @property - def a_parameter(self) -> float: - return ( - PENG_ROBINSON_A_COEFFICIENT - * UNIVERSAL_GAS_CONSTANT - * UNIVERSAL_GAS_CONSTANT - * self.critical_temperature - * self.critical_temperature - / self.critical_pressure - ) - - @property - def b_parameter(self) -> float: - return ( - PENG_ROBINSON_B_COEFFICIENT - * UNIVERSAL_GAS_CONSTANT - * self.critical_temperature - / self.critical_pressure - ) - - @property - def kappa(self) -> float: - omega = self.acentric_factor - return 0.37464 + 1.54226 * omega - 0.26992 * omega * omega - - def alpha(self, temperature: float) -> float: - self._validate_temperature(temperature) - reduced_temperature = temperature / self.critical_temperature - return (1.0 + self.kappa * (1.0 - sqrt(reduced_temperature))) ** 2.0 - - def alpha_temperature_derivative(self, temperature: float) -> float: - self._validate_temperature(temperature) - reduced_temperature = temperature / self.critical_temperature - sqrt_reduced_temperature = sqrt(reduced_temperature) - alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature) - return -( - alpha_base - * self.kappa - / (self.critical_temperature * sqrt_reduced_temperature) - ) - - def alpha_temperature_second_derivative(self, temperature: float) -> float: - self._validate_temperature(temperature) - reduced_temperature = temperature / self.critical_temperature - sqrt_reduced_temperature = sqrt(reduced_temperature) - alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature) - return ( - self.kappa - / (2.0 * self.critical_temperature * self.critical_temperature) - * ( - self.kappa / reduced_temperature - + alpha_base / (reduced_temperature * sqrt_reduced_temperature) - ) - ) - - def attractive_parameter(self, temperature: float) -> float: - return self.a_parameter * self.alpha(temperature) - - def attractive_parameter_temperature_derivative(self, temperature: float) -> float: - return self.a_parameter * self.alpha_temperature_derivative(temperature) - - def attractive_parameter_temperature_second_derivative( - self, - temperature: float, - ) -> float: - return self.a_parameter * self.alpha_temperature_second_derivative(temperature) - - @profile_property( - "pressure_from_molar_volume", - layer="kernel", - minimum_mode="audit", - ) - def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float: - self._validate_temperature(temperature) - if molar_volume <= self.b_parameter: - raise RecoverableTrialStateError("Molar volume must be larger than Peng-Robinson b parameter.") - a_alpha = self.attractive_parameter(temperature) - b = self.b_parameter - repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b) - attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b)) - return repulsive - attractive - - @profile_property( - "pressure_from_density", - layer="kernel", - minimum_mode="audit", - ) - def pressure_from_density(self, temperature: float, density: float) -> float: - if density <= 0.0: - raise ValueError("Density must be positive.") - return self.pressure_from_molar_volume(temperature, self.molar_mass / density) - - @profile_property( - "pressure_temperature_derivative_at_density", - layer="kernel", - minimum_mode="audit", - ) - def pressure_temperature_derivative_at_density( - self, - temperature: float, - density: float, - ) -> float: - self._validate_temperature(temperature) - if density <= 0.0: - raise ValueError("Density must be positive.") - molar_volume = self.molar_mass / density - if molar_volume <= self.b_parameter: - raise RecoverableTrialStateError( - "Molar volume must be larger than Peng-Robinson b parameter." - ) - b = self.b_parameter - denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b) - return ( - UNIVERSAL_GAS_CONSTANT / (molar_volume - b) - - self.attractive_parameter_temperature_derivative(temperature) / denominator - ) - - @profile_property( - "pressure_density_derivative_at_temperature", - layer="kernel", - minimum_mode="audit", - ) - def pressure_density_derivative_at_temperature( - self, - temperature: float, - density: float, - ) -> float: - self._validate_temperature(temperature) - if density <= 0.0: - raise ValueError("Density must be positive.") - molar_volume = self.molar_mass / density - if molar_volume <= self.b_parameter: - raise RecoverableTrialStateError( - "Molar volume must be larger than Peng-Robinson b parameter." - ) - b = self.b_parameter - denominator = molar_volume * (molar_volume + b) + b * (molar_volume - b) - pressure_molar_volume_derivative = ( - -UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b) ** 2 - + self.attractive_parameter(temperature) - * 2.0 - * (molar_volume + b) - / denominator**2 - ) - molar_volume_density_derivative = -self.molar_mass / (density * density) - return pressure_molar_volume_derivative * molar_volume_density_derivative - - def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]: - self._validate_pressure_temperature(pressure, temperature) - a_alpha = self.attractive_parameter(temperature) - b = self.b_parameter - A = a_alpha * pressure / (UNIVERSAL_GAS_CONSTANT * UNIVERSAL_GAS_CONSTANT * temperature * temperature) - B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature) - return A, B - - @profile_property( - "compressibility_roots", - layer="kernel", - minimum_mode="audit", - ) - def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]: - A, B = self.reduced_parameters(pressure, temperature) - coefficients = ( - -(1.0 - B), - A - 3.0 * B * B - 2.0 * B, - -(A * B - B * B - B * B * B), - ) - roots = _real_cubic_roots(*coefficients) - physical_roots = tuple(sorted(root for root in roots if root > B and isfinite(root))) - if not physical_roots: - raise ValueError("Peng-Robinson cubic produced no physical compressibility root.") - return physical_roots - - @profile_property( - "compressibility_factor", - layer="kernel", - minimum_mode="audit", - ) - def compressibility_factor( - self, - pressure: float, - temperature: float, - phase: str = "vapor", - ) -> float: - roots = self.compressibility_roots(pressure, temperature) - if phase == "vapor": - return roots[-1] - if phase == "liquid": - return roots[0] - if phase == "stable-single-root": - return roots[-1] - raise ValueError(f"Unsupported phase selector: {phase!r}") - - @profile_property( - "molar_volume", - layer="kernel", - minimum_mode="audit", - ) - def molar_volume( - self, - pressure: float, - temperature: float, - phase: str = "vapor", - ) -> float: - z = self.compressibility_factor(pressure, temperature, phase=phase) - return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure - - @profile_property("density", layer="kernel", minimum_mode="audit") - def density( - self, - pressure: float, - temperature: float, - phase: str = "vapor", - ) -> float: - return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase) - - @profile_property( - "residual_specific_enthalpy", - layer="kernel", - minimum_mode="audit", - ) - def residual_specific_enthalpy( - self, - pressure: float, - temperature: float, - phase: str = "vapor", - ) -> float: - """Return Peng-Robinson enthalpy departure from ideal gas, J/kg.""" - self._validate_pressure_temperature(pressure, temperature) - z = self.compressibility_factor(pressure, temperature, phase=phase) - _, B = self.reduced_parameters(pressure, temperature) - b = self.b_parameter - attractive = self.attractive_parameter(temperature) - d_attractive_d_temperature = ( - self.attractive_parameter_temperature_derivative(temperature) - ) - log_argument = (z + (1.0 + sqrt(2.0)) * B) / ( - z + (1.0 - sqrt(2.0)) * B - ) - residual_molar_enthalpy = ( - UNIVERSAL_GAS_CONSTANT * temperature * (z - 1.0) - + ( - temperature * d_attractive_d_temperature - - attractive - ) - * log(log_argument) - / (2.0 * sqrt(2.0) * b) - ) - return residual_molar_enthalpy / self.molar_mass - - @profile_property( - "residual_specific_internal_energy_at_density", - layer="kernel", - minimum_mode="audit", - ) - def residual_specific_internal_energy_at_density( - self, - temperature: float, - density: float, - ) -> float: - """Return Peng-Robinson internal-energy departure, J/kg.""" - self._validate_temperature(temperature) - if density <= 0.0: - raise ValueError("Density must be positive.") - molar_volume = self.molar_mass / density - b = self.b_parameter - if molar_volume <= b: - raise RecoverableTrialStateError( - "Molar volume must be larger than Peng-Robinson b parameter." - ) - attractive = self.attractive_parameter(temperature) - d_attractive_d_temperature = ( - self.attractive_parameter_temperature_derivative(temperature) - ) - log_argument = ( - molar_volume + (1.0 + sqrt(2.0)) * b - ) / ( - molar_volume + (1.0 - sqrt(2.0)) * b - ) - residual_molar_internal_energy = ( - temperature * d_attractive_d_temperature - attractive - ) * log(log_argument) / (2.0 * sqrt(2.0) * b) - return residual_molar_internal_energy / self.molar_mass - - @profile_property( - "residual_isochoric_heat_capacity_at_density", - layer="kernel", - minimum_mode="audit", - ) - def residual_isochoric_heat_capacity_at_density( - self, - temperature: float, - density: float, - ) -> float: - """Return the constant-volume heat-capacity departure, J/kg/K.""" - self._validate_temperature(temperature) - if density <= 0.0: - raise ValueError("Density must be positive.") - molar_volume = self.molar_mass / density - b = self.b_parameter - if molar_volume <= b: - raise RecoverableTrialStateError( - "Molar volume must be larger than Peng-Robinson b parameter." - ) - log_argument = ( - molar_volume + (1.0 + sqrt(2.0)) * b - ) / ( - molar_volume + (1.0 - sqrt(2.0)) * b - ) - residual_molar_cv = ( - temperature - * self.attractive_parameter_temperature_second_derivative(temperature) - * log(log_argument) - / (2.0 * sqrt(2.0) * b) - ) - return residual_molar_cv / self.molar_mass - - @staticmethod - def _validate_temperature(temperature: float) -> None: - if temperature <= 0.0: - raise RecoverableTrialStateError("Temperature must be positive.") - - @classmethod - def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None: - if pressure <= 0.0: - raise RecoverableTrialStateError("Pressure must be positive.") - cls._validate_temperature(temperature) - -HELIUM_PR = PengRobinsonFluid( - name="helium", - molar_mass=0.004002602, - critical_temperature=5.1953, - critical_pressure=227_460.0, - # Simcenter Amesim 2404 helium_eos.data. - acentric_factor=-0.382, -) - -NITROGEN_PR = PengRobinsonFluid( - name="nitrogen", - molar_mass=0.0280134, - critical_temperature=126.192, - critical_pressure=3.3958e6, - acentric_factor=0.0372, -) - -AIR_PR = PengRobinsonFluid( - name="air", - molar_mass=0.02896513, - critical_temperature=132.5306, - critical_pressure=3.786e6, - acentric_factor=0.0335, -) - - -def _real_cubic_roots(a: float, b: float, c: float) -> tuple[float, ...]: - """Return real roots for x**3 + a*x**2 + b*x + c = 0.""" - - depressed_p = b - a * a / 3.0 - depressed_q = 2.0 * a * a * a / 27.0 - a * b / 3.0 + c - discriminant = (depressed_q / 2.0) ** 2.0 + (depressed_p / 3.0) ** 3.0 - offset = -a / 3.0 - tolerance = 1e-14 - - if discriminant > tolerance: - sqrt_discriminant = sqrt(discriminant) - u = _real_cube_root(-depressed_q / 2.0 + sqrt_discriminant) - v = _real_cube_root(-depressed_q / 2.0 - sqrt_discriminant) - return (u + v + offset,) - - if abs(discriminant) <= tolerance: - u = _real_cube_root(-depressed_q / 2.0) - return tuple(sorted({2.0 * u + offset, -u + offset})) - - if depressed_p >= 0.0: - raise ValueError("Unexpected cubic state with three real roots and non-negative p.") - radius = 2.0 * sqrt(-depressed_p / 3.0) - argument = (3.0 * depressed_q / (2.0 * depressed_p)) * sqrt(-3.0 / depressed_p) - argument = max(-1.0, min(1.0, argument)) - theta = acos(argument) / 3.0 - roots = [ - radius * cos(theta - 2.0 * pi * index / 3.0) + offset - for index in range(3) - ] - return tuple(sorted(roots)) - - -def _real_cube_root(value: float) -> float: - if value == 0.0: - return 0.0 - return (1.0 if value > 0.0 else -1.0) * abs(value) ** (1.0 / 3.0) diff --git a/app/simulation/core/state.py b/app/simulation/core/state.py deleted file mode 100644 index e4cf3b3..0000000 --- a/app/simulation/core/state.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class VolumeState: - """Primary dynamic state for rigid adiabatic control volumes.""" - - m: float - U: float - - def as_vector(self) -> list[float]: - return [self.m, self.U] - - @classmethod - def from_vector(cls, values: list[float]) -> "VolumeState": - if len(values) != 2: - raise ValueError("VolumeState requires exactly two values: [m, U].") - return cls(m=values[0], U=values[1]) - diff --git a/app/simulation/examples/__init__.py b/app/simulation/examples/__init__.py deleted file mode 100644 index a8eeb1e..0000000 --- a/app/simulation/examples/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Reference systems and regression examples.""" diff --git a/app/simulation/examples/test_mql/__init__.py b/app/simulation/examples/test_mql/__init__.py deleted file mode 100644 index 72c62c3..0000000 --- a/app/simulation/examples/test_mql/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from app.simulation.examples.test_mql.system import ( - TestMqlRunConfig, - TestMqlSimulationResult, - TestMqlSystem, -) -from app.simulation.examples.test_mql.run import ( - PreparedTestMqlRun, - TestMqlRunResult, - prepare_test_mql_run, - run_prepared_test_mql, - run_test_mql, -) - -__all__ = [ - "PreparedTestMqlRun", - "TestMqlRunConfig", - "TestMqlRunResult", - "TestMqlSimulationResult", - "TestMqlSystem", - "prepare_test_mql_run", - "run_prepared_test_mql", - "run_test_mql", -] diff --git a/app/simulation/examples/test_mql/baseline.py b/app/simulation/examples/test_mql/baseline.py deleted file mode 100644 index 8bbeb45..0000000 --- a/app/simulation/examples/test_mql/baseline.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results -from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult -from app.simulation.reporting.test_mql_observations import ( - TestMqlObservationCatalog, - build_test_mql_observation_catalog, -) -from app.simulation.reporting.test_mql_output_schema import ( - TestMqlOutputSchema, - build_test_mql_output_schema, -) -from app.simulation.reporting.test_mql_output_validation import ( - TestMqlValidatedOutput, - compare_validated_test_mql_output, - validate_test_mql_output, -) - - -@dataclass(frozen=True) -class TestMqlBaselineRun: - amesim_results: AmesimResults - observation_catalog: TestMqlObservationCatalog - output_schema: TestMqlOutputSchema - output: TestMqlValidatedOutput - comparison: TestMqlComparisonResult - - @property - def sample_count(self) -> int: - return len(self.output.times) - - @property - def signal_count(self) -> int: - return len(self.output.data_paths) - - -def run_test_mql_baseline_passthrough( - archive_path: Path, - *, - data_paths: tuple[str, ...] | list[str] | None = None, -) -> TestMqlBaselineRun: - amesim_results = load_test_mql_amesim_results(archive_path) - observation_catalog = build_test_mql_observation_catalog(amesim_results) - output_schema = build_test_mql_output_schema( - amesim_results, - observation_catalog=observation_catalog, - ) - selected_paths = tuple(data_paths) if data_paths is not None else output_schema.data_paths() - baseline_series = observation_catalog.baseline_series_by_data_path( - amesim_results, - selected_paths, - ) - output = validate_test_mql_output( - times=amesim_results.times, - series_by_data_path=baseline_series, - schema=output_schema, - data_paths=selected_paths, - require_all_schema_paths=data_paths is None, - ) - comparison = compare_validated_test_mql_output( - times=output.times, - series_by_data_path=output.series_by_data_path, - schema=output_schema, - amesim_results=amesim_results, - data_paths=output.data_paths, - require_all_schema_paths=data_paths is None, - ) - return TestMqlBaselineRun( - amesim_results=amesim_results, - observation_catalog=observation_catalog, - output_schema=output_schema, - output=output, - comparison=comparison, - ) diff --git a/app/simulation/examples/test_mql/closure.py b/app/simulation/examples/test_mql/closure.py deleted file mode 100644 index f31d14c..0000000 --- a/app/simulation/examples/test_mql/closure.py +++ /dev/null @@ -1,4213 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Callable - -from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticGas, - AmesimPneumaticOrifice, - AmesimPneumaticVolume, -) -from app.simulation.examples.test_mql.primitives.pneumatic_lines import ( - AmesimPnl0001Pipe, - AmesimPnl0002Pipe, - AmesimPnl0003Pipe, - AmesimPnl00rPipe, -) -from app.simulation.core.medium import ThermodynamicProperties -from app.simulation.core.ports import PortState -from app.simulation.core.state import VolumeState -from app.simulation.examples.test_mql.nodes import ( - TestMqlPneumaticNode3, - TestMqlPneumaticNode3Balance, - TestMqlPneumaticNode4, - TestMqlPneumaticNode4Balance, -) - - -@dataclass(frozen=True) -class TestMqlPneumaticBranchSpec: - name: str - upstream_volume_alias: str - orifice_alias: str - downstream_volume_alias: str - source: str = "manual" - - -@dataclass(frozen=True) -class TestMqlPneumaticTopologyCandidate: - connection_alias: str - line_submodel: str - source_component: str - source_port: str - target_component: str - target_port: str - source_is_typed_pneumatic: bool - target_is_typed_pneumatic: bool - category: str - reason: str - - -@dataclass(frozen=True) -class TestMqlPneumaticChamberSegmentSpec: - name: str - inlet_node_alias: str - inlet_line_alias: str - inlet_orifice_alias: str - inlet_orifice_boundary_port: str - inlet_orifice_volume_port: str - volume_alias: str - volume_inlet_port: str - volume_outlet_port: str - outlet_orifice_alias: str - outlet_orifice_volume_port: str - outlet_orifice_boundary_port: str - outlet_line_alias: str - outlet_node_alias: str - source: str = "amesim-cir-topology" - - -@dataclass(frozen=True) -class TestMqlPneumaticTopologyDiscovery: - branch_specs: tuple[TestMqlPneumaticBranchSpec, ...] - chamber_segment_specs: tuple[TestMqlPneumaticChamberSegmentSpec, ...] - blocked_candidates: tuple[TestMqlPneumaticTopologyCandidate, ...] - - -@dataclass(frozen=True) -class TestMqlPneumaticBoundaryCondition: - pressure_pa: float - temperature_k: float - - def properties(self, gas: AmesimPneumaticGas) -> ThermodynamicProperties: - if self.pressure_pa <= 0.0: - raise ValueError("boundary pressure must be positive") - if self.temperature_k <= 0.0: - raise ValueError("boundary temperature must be positive") - return ThermodynamicProperties( - p=self.pressure_pa, - T=self.temperature_k, - rho=gas.density(self.pressure_pa, self.temperature_k), - u=gas.specific_internal_energy(self.temperature_k), - h=gas.specific_enthalpy(self.temperature_k), - ) - - -@dataclass(frozen=True) -class TestMqlPneumaticChamberSegmentComponents: - volume: AmesimPneumaticVolume - inlet_orifice: AmesimPneumaticOrifice - outlet_orifice: AmesimPneumaticOrifice - spec: TestMqlPneumaticChamberSegmentSpec - - -@dataclass(frozen=True) -class TestMqlPneumaticChamberSegmentSnapshot: - chamber: ThermodynamicProperties - inlet_boundary: ThermodynamicProperties - outlet_boundary: ThermodynamicProperties - inlet_flow: float - outlet_flow: float - - -@dataclass(frozen=True) -class TestMqlPnl0001ChamberSegmentComponents: - inlet_line: AmesimPnl0001Pipe - volume: AmesimPneumaticVolume - inlet_orifice: AmesimPneumaticOrifice - outlet_orifice: AmesimPneumaticOrifice - spec: TestMqlPneumaticChamberSegmentSpec - - -@dataclass(frozen=True) -class TestMqlPnl0001ChamberSegmentSnapshot: - inlet_line: ThermodynamicProperties - chamber: ThermodynamicProperties - inlet_node: ThermodynamicProperties - outlet_boundary: ThermodynamicProperties - node_to_line_flow: float - line_to_chamber_flow: float - outlet_flow: float - - -@dataclass(frozen=True) -class TestMqlPnl0001PairChamberSegmentComponents: - inlet_line: AmesimPnl0001Pipe - outlet_line: AmesimPnl0001Pipe - volume: AmesimPneumaticVolume - inlet_orifice: AmesimPneumaticOrifice - outlet_orifice: AmesimPneumaticOrifice - spec: TestMqlPneumaticChamberSegmentSpec - - -@dataclass(frozen=True) -class TestMqlPnl0001PairChamberSegmentSnapshot: - inlet_line: ThermodynamicProperties - chamber: ThermodynamicProperties - outlet_line: ThermodynamicProperties - inlet_node: ThermodynamicProperties - outlet_node: ThermodynamicProperties - inlet_node_to_line_flow: float - inlet_line_to_chamber_flow: float - outlet_node_to_line_flow: float - outlet_line_to_chamber_flow: float - - -@dataclass(frozen=True) -class TestMqlPn3NodeChamberSegmentComponents: - inlet_line: AmesimPnl0001Pipe - outlet_line: AmesimPnl0001Pipe - node_line: AmesimPnl0003Pipe - node_resistance: AmesimPnl00rPipe - node_orifice: AmesimPneumaticOrifice - node: TestMqlPneumaticNode3 - volume: AmesimPneumaticVolume - inlet_orifice: AmesimPneumaticOrifice - outlet_orifice: AmesimPneumaticOrifice - spec: TestMqlPneumaticChamberSegmentSpec - - -@dataclass(frozen=True) -class TestMqlPn3NodeChamberSegmentSnapshot: - inlet_line: ThermodynamicProperties - chamber: ThermodynamicProperties - outlet_line: ThermodynamicProperties - node_line_port_1: ThermodynamicProperties - node_line_port_2: ThermodynamicProperties - inlet_node: ThermodynamicProperties - resistance_boundary: ThermodynamicProperties - p4_boundary: ThermodynamicProperties - node_balance: TestMqlPneumaticNode3Balance - inlet_node_to_line_flow: float - inlet_line_to_chamber_flow: float - outlet_node_to_line_flow: float - outlet_line_to_chamber_flow: float - node_to_resistance_flow: float - node_to_pnl0003_flow: float - pnl0003_center_flow: float - pnl0003_to_p4_flow: float - - -@dataclass(frozen=True) -class TestMqlPn3P4NodeChamberSegmentComponents: - inlet_line: AmesimPnl0001Pipe - outlet_line: AmesimPnl0001Pipe - node_line: AmesimPnl0003Pipe - node_resistance: AmesimPnl00rPipe - node_orifice: AmesimPneumaticOrifice - node: TestMqlPneumaticNode3 - p4_node: TestMqlPneumaticNode4 - p4_primary_line: AmesimPnl0001Pipe - p4_primary_chamber: AmesimPneumaticVolume - p4_port1_line: AmesimPnl0002Pipe - p4_port1_remote_node: TestMqlPneumaticNode4 - p4_port1_remote_primary_line: AmesimPnl0001Pipe - p4_port1_remote_primary_chamber: AmesimPneumaticVolume - p4_port1_remote_port1_line: AmesimPnl0002Pipe - p4_port1_far_node: TestMqlPneumaticNode4 - p4_port1_far_primary_line: AmesimPnl0001Pipe - p4_port1_far_primary_chamber: AmesimPneumaticVolume - p4_port1_far_port1_line: AmesimPnl0002Pipe - p4_port1_next_node: TestMqlPneumaticNode4 - p4_port1_next_primary_line: AmesimPnl0001Pipe - p4_port1_next_primary_chamber: AmesimPneumaticVolume - p4_port1_next_port1_line: AmesimPnl0002Pipe - p4_port1_next_orifice: AmesimPneumaticOrifice - p4_port1_next_orifice_line: AmesimPnl0003Pipe - p4_port1_next_orifice_node: TestMqlPneumaticNode3 - p4_port1_next_orifice_output_line: AmesimPnl0001Pipe - p4_port1_next_orifice_resistance: AmesimPnl00rPipe - p4_port1_next_orifice_resistance_node: TestMqlPneumaticNode3 - p4_port1_next_orifice_resistance_line: AmesimPnl0003Pipe - p4_port1_next_orifice_resistance_orifice: AmesimPneumaticOrifice - p4_port1_next_orifice_resistance_chamber_inlet_line: AmesimPnl0001Pipe - p4_port1_next_orifice_resistance_chamber: AmesimPneumaticVolume - p4_port1_next_orifice_resistance_chamber_outlet_line: AmesimPnl0001Pipe - p4_port1_next_orifice_resistance_chamber_inlet_orifice: AmesimPneumaticOrifice - p4_port1_next_orifice_resistance_chamber_outlet_orifice: AmesimPneumaticOrifice - p4_port1_next_orifice_resistance_chamber_spec: TestMqlPneumaticChamberSegmentSpec - p4_bridge_node: TestMqlPneumaticNode4 - p4_bridge_primary_line: AmesimPnl0001Pipe - p4_bridge_primary_chamber: AmesimPneumaticVolume - p4_port1_remote_orifice: AmesimPneumaticOrifice - p4_port1_remote_orifice_line: AmesimPnl0003Pipe - p4_port1_remote_orifice_node: TestMqlPneumaticNode3 - p4_port3_line: AmesimPnl0002Pipe - p4_port3_remote_node: TestMqlPneumaticNode4 - p4_port3_remote_primary_line: AmesimPnl0001Pipe - p4_port3_remote_primary_chamber: AmesimPneumaticVolume - p4_port3_remote_port3_line: AmesimPnl0002Pipe - p4_port3_far_node: TestMqlPneumaticNode4 - p4_port3_far_primary_line: AmesimPnl0001Pipe - p4_port3_far_primary_chamber: AmesimPneumaticVolume - p4_port3_far_port3_line: AmesimPnl0002Pipe - p4_port3_next_node: TestMqlPneumaticNode4 - p4_port3_next_primary_line: AmesimPnl0001Pipe - p4_port3_next_primary_chamber: AmesimPneumaticVolume - p4_port3_next_port3_line: AmesimPnl0002Pipe - p4_port3_next_orifice: AmesimPneumaticOrifice - p4_port3_next_orifice_line: AmesimPnl0003Pipe - p4_port3_next_orifice_node: TestMqlPneumaticNode3 - p4_port3_next_orifice_output_line: AmesimPnl0001Pipe - p4_port3_next_chamber_inlet_line: AmesimPnl0001Pipe - p4_port3_next_chamber: AmesimPneumaticVolume - p4_port3_next_chamber_outlet_line: AmesimPnl0001Pipe - p4_port3_next_chamber_inlet_orifice: AmesimPneumaticOrifice - p4_port3_next_chamber_outlet_orifice: AmesimPneumaticOrifice - p4_port3_next_chamber_spec: TestMqlPneumaticChamberSegmentSpec - p4_port3_next_orifice_resistance: AmesimPnl00rPipe - p4_port3_next_resistance_node: TestMqlPneumaticNode3 - p4_port3_next_resistance_node_line: AmesimPnl0003Pipe - p4_port3_next_resistance_orifice: AmesimPneumaticOrifice - p4_port3_next_resistance_output_line: AmesimPnl0001Pipe - p4_port3_next_resistance_chamber_inlet_line: AmesimPnl0001Pipe - p4_port3_next_resistance_chamber: AmesimPneumaticVolume - p4_port3_next_resistance_chamber_outlet_line: AmesimPnl0001Pipe - p4_port3_next_resistance_chamber_inlet_orifice: AmesimPneumaticOrifice - p4_port3_next_resistance_chamber_outlet_orifice: AmesimPneumaticOrifice - p4_port3_next_resistance_chamber_spec: TestMqlPneumaticChamberSegmentSpec - p4_port3_remote_orifice: AmesimPneumaticOrifice - p4_port3_remote_orifice_line: AmesimPnl0003Pipe - p4_port3_remote_orifice_node: TestMqlPneumaticNode3 - p4_port3_remote_orifice_resistance: AmesimPnl00rPipe - p4_port3_remote_resistance_node: TestMqlPneumaticNode3 - p4_port3_remote_resistance_node_line: AmesimPnl0003Pipe - p4_port3_remote_resistance_orifice: AmesimPneumaticOrifice - p4_port3_remote_resistance_output_line: AmesimPnl0001Pipe - volume: AmesimPneumaticVolume - inlet_orifice: AmesimPneumaticOrifice - outlet_orifice: AmesimPneumaticOrifice - spec: TestMqlPneumaticChamberSegmentSpec - - -@dataclass(frozen=True) -class TestMqlTwoStateOrificeLineFlows: - line_center_flow: float - line_to_boundary_flow: float - - -@dataclass(frozen=True) -class TestMqlP4NeighborhoodLineFlows: - primary_chamber_to_line_flow: float - port_1_line_flow: float - port_3_line_flow: float - - -@dataclass(frozen=True) -class TestMqlFixedChamberSegmentFlows: - inlet_node_to_line_flow: float - inlet_line_to_chamber_flow: float - outlet_node_to_line_flow: float - outlet_line_to_chamber_flow: float - - -@dataclass(frozen=True) -class TestMqlPn3NodeBalancePort: - flow_kg_s: float - node_h: float - connected_h: float - port_mass_flow_kg_s: float - - -@dataclass(frozen=True) -class TestMqlP4NodeBalancePort: - flow_kg_s: float - node_h: float - connected_h: float - port_mass_flow_kg_s: float - - -@dataclass(frozen=True) -class TestMqlPn3P4NodeChamberSegmentSnapshot: - inlet_line: ThermodynamicProperties - chamber: ThermodynamicProperties - outlet_line: ThermodynamicProperties - node_line_port_1: ThermodynamicProperties - node_line_port_2: ThermodynamicProperties - p4_primary_line: ThermodynamicProperties - p4_boundary: ThermodynamicProperties - p4_primary_chamber: ThermodynamicProperties - p4_port1_line: ThermodynamicProperties - p4_port1_remote_primary_line: ThermodynamicProperties - p4_port1_remote_primary_chamber: ThermodynamicProperties - p4_port1_remote_port1_line: ThermodynamicProperties - p4_port1_far_primary_line: ThermodynamicProperties - p4_port1_far_primary_chamber: ThermodynamicProperties - p4_port1_far_port1_line: ThermodynamicProperties - p4_port1_next_primary_line: ThermodynamicProperties - p4_port1_next_primary_chamber: ThermodynamicProperties - p4_port1_next_port1_line: ThermodynamicProperties - p4_port1_next_orifice_line_port_1: ThermodynamicProperties - p4_port1_next_orifice_line_port_2: ThermodynamicProperties - p4_port1_next_orifice_output_line: ThermodynamicProperties - p4_port1_next_orifice_resistance_line_port_1: ThermodynamicProperties - p4_port1_next_orifice_resistance_line_port_2: ThermodynamicProperties - p4_port1_next_orifice_resistance_chamber_inlet_line: ThermodynamicProperties - p4_port1_next_orifice_resistance_chamber: ThermodynamicProperties - p4_port1_next_orifice_resistance_chamber_outlet_line: ThermodynamicProperties - p4_bridge_primary_line: ThermodynamicProperties - p4_bridge_primary_chamber: ThermodynamicProperties - p4_port1_remote_orifice_line_port_1: ThermodynamicProperties - p4_port1_remote_orifice_line_port_2: ThermodynamicProperties - p4_port3_line: ThermodynamicProperties - p4_port3_remote_primary_line: ThermodynamicProperties - p4_port3_remote_primary_chamber: ThermodynamicProperties - p4_port3_remote_port3_line: ThermodynamicProperties - p4_port3_far_primary_line: ThermodynamicProperties - p4_port3_far_primary_chamber: ThermodynamicProperties - p4_port3_far_port3_line: ThermodynamicProperties - p4_port3_next_primary_line: ThermodynamicProperties - p4_port3_next_primary_chamber: ThermodynamicProperties - p4_port3_next_port3_line: ThermodynamicProperties - p4_port3_next_orifice_line_port_1: ThermodynamicProperties - p4_port3_next_orifice_line_port_2: ThermodynamicProperties - p4_port3_next_orifice_output_line: ThermodynamicProperties - p4_port3_next_chamber_inlet_line: ThermodynamicProperties - p4_port3_next_chamber: ThermodynamicProperties - p4_port3_next_chamber_outlet_line: ThermodynamicProperties - p4_port3_next_resistance_node_line_port_1: ThermodynamicProperties - p4_port3_next_resistance_node_line_port_2: ThermodynamicProperties - p4_port3_next_resistance_output_line: ThermodynamicProperties - p4_port3_next_resistance_chamber_inlet_line: ThermodynamicProperties - p4_port3_next_resistance_chamber: ThermodynamicProperties - p4_port3_next_resistance_chamber_outlet_line: ThermodynamicProperties - p4_port3_remote_orifice_line_port_1: ThermodynamicProperties - p4_port3_remote_orifice_line_port_2: ThermodynamicProperties - p4_port3_remote_resistance_node_line_port_1: ThermodynamicProperties - p4_port3_remote_resistance_node_line_port_2: ThermodynamicProperties - p4_port3_remote_resistance_output_line: ThermodynamicProperties - inlet_node: ThermodynamicProperties - resistance_boundary: ThermodynamicProperties - node_balance: TestMqlPneumaticNode3Balance - p4_balance: TestMqlPneumaticNode4Balance - p4_port1_remote_balance: TestMqlPneumaticNode4Balance - p4_port1_far_balance: TestMqlPneumaticNode4Balance - p4_port1_next_balance: TestMqlPneumaticNode4Balance - p4_bridge_balance: TestMqlPneumaticNode4Balance - p4_port1_remote_orifice_node_balance: TestMqlPneumaticNode3Balance - p4_port1_next_orifice_node_balance: TestMqlPneumaticNode3Balance - p4_port1_next_orifice_resistance_node_balance: TestMqlPneumaticNode3Balance - p4_port3_remote_balance: TestMqlPneumaticNode4Balance - p4_port3_far_balance: TestMqlPneumaticNode4Balance - p4_port3_next_balance: TestMqlPneumaticNode4Balance - p4_port3_next_orifice_node_balance: TestMqlPneumaticNode3Balance - p4_port3_next_resistance_node_balance: TestMqlPneumaticNode3Balance - p4_port3_remote_orifice_node_balance: TestMqlPneumaticNode3Balance - p4_port3_remote_resistance_node_balance: TestMqlPneumaticNode3Balance - inlet_node_to_line_flow: float - inlet_line_to_chamber_flow: float - outlet_node_to_line_flow: float - outlet_line_to_chamber_flow: float - node_to_resistance_flow: float - node_to_pnl0003_flow: float - pnl0003_center_flow: float - pnl0003_to_p4_flow: float - p4_primary_chamber_to_line_flow: float - p4_node_to_primary_line_flow: float - p4_to_port1_line_flow: float - p4_port1_remote_node_to_line_flow: float - p4_port1_remote_chamber_to_line_flow: float - p4_port1_remote_node_to_primary_line_flow: float - p4_port1_remote_to_port1_line_flow: float - p4_port1_far_node_to_line_flow: float - p4_port1_far_chamber_to_line_flow: float - p4_port1_far_node_to_primary_line_flow: float - p4_port1_far_to_next_line_flow: float - p4_port1_next_node_to_line_flow: float - p4_port1_next_chamber_to_line_flow: float - p4_port1_next_node_to_primary_line_flow: float - p4_port1_next_to_bridge_line_flow: float - p4_port1_next_orifice_p4_to_output_line_flow: float - p4_port1_next_orifice_node_to_line_flow: float - p4_port1_next_orifice_line_center_flow: float - p4_port1_next_orifice_to_output_line_flow: float - p4_port1_next_orifice_resistance_node_to_next_flow: float - p4_port1_next_orifice_resistance_node_to_line_flow: float - p4_port1_next_orifice_resistance_line_center_flow: float - p4_port1_next_orifice_resistance_to_p4_flow: float - p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow: float - p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow: float - p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow: float - p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow: float - p4_bridge_to_port1_next_line_flow: float - p4_bridge_chamber_to_line_flow: float - p4_bridge_node_to_primary_line_flow: float - p4_port1_remote_node_to_orifice_line_flow: float - p4_port1_remote_orifice_line_center_flow: float - p4_port1_remote_orifice_to_node_flow: float - p4_to_port3_line_flow: float - p4_port3_remote_node_to_line_flow: float - p4_port3_remote_chamber_to_line_flow: float - p4_port3_remote_node_to_primary_line_flow: float - p4_port3_remote_to_port3_line_flow: float - p4_port3_far_node_to_line_flow: float - p4_port3_far_chamber_to_line_flow: float - p4_port3_far_node_to_primary_line_flow: float - p4_port3_far_to_next_line_flow: float - p4_port3_next_node_to_line_flow: float - p4_port3_next_chamber_to_line_flow: float - p4_port3_next_node_to_primary_line_flow: float - p4_bridge_to_port3_next_line_flow: float - p4_port3_next_to_bridge_line_flow: float - p4_port3_next_orifice_node_to_line_flow: float - p4_port3_next_orifice_line_center_flow: float - p4_port3_next_orifice_to_output_line_flow: float - p4_port3_next_orifice_output_to_p4_flow: float - p4_port3_next_chamber_inlet_node_to_line_flow: float - p4_port3_next_chamber_inlet_line_to_chamber_flow: float - p4_port3_next_chamber_outlet_node_to_line_flow: float - p4_port3_next_chamber_outlet_line_to_chamber_flow: float - p4_port3_next_resistance_node_to_next_flow: float - p4_port3_next_resistance_node_to_line_flow: float - p4_port3_next_resistance_line_center_flow: float - p4_port3_next_resistance_to_output_line_flow: float - p4_port3_next_resistance_output_to_p4_flow: float - p4_port3_next_resistance_chamber_inlet_node_to_line_flow: float - p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow: float - p4_port3_next_resistance_chamber_outlet_node_to_line_flow: float - p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow: float - p4_port3_remote_node_to_orifice_line_flow: float - p4_port3_remote_node_to_resistance_flow: float - p4_port3_remote_resistance_node_to_line_flow: float - p4_port3_remote_resistance_line_center_flow: float - p4_port3_remote_resistance_to_output_line_flow: float - p4_port3_remote_resistance_output_to_p4_flow: float - p4_port3_remote_orifice_line_center_flow: float - p4_port3_remote_orifice_to_node_flow: float - - -class TestMqlPn3P4NodeChamberSegmentClosure: - """PN3 chamber segment closed through the adjacent real P4 node. - - The P4 primary pressure/temperature comes from the PNL0001 storage on - ``port_2``. P4 ports 1 and 3 are represented by the adjacent PNL0002 center - compliances; their far nodes remain prescribed pressure boundaries. - """ - - def __init__( - self, - *, - components: TestMqlPn3P4NodeChamberSegmentComponents, - inlet_node: TestMqlPneumaticBoundaryCondition, - resistance_boundary: TestMqlPneumaticBoundaryCondition, - use_inlet_line_reference_rhs: bool = False, - use_p4_port3_remote_primary_reference_rhs: bool = False, - ) -> None: - self.components = components - self.inlet_node = inlet_node - self.resistance_boundary = resistance_boundary - self.use_inlet_line_reference_rhs = use_inlet_line_reference_rhs - self.use_p4_port3_remote_primary_reference_rhs = ( - use_p4_port3_remote_primary_reference_rhs - ) - - def initial_state_vector(self) -> list[float]: - return [ - *self.components.inlet_line.get_state_vector(), - *self.components.volume.get_state_vector(), - *self.components.outlet_line.get_state_vector(), - *self.components.node_line.get_state_vector(), - *self.components.p4_primary_line.get_state_vector(), - *self.components.p4_primary_chamber.get_state_vector(), - *self.components.p4_port1_line.get_state_vector(), - *self.components.p4_port1_remote_primary_line.get_state_vector(), - *self.components.p4_port1_remote_primary_chamber.get_state_vector(), - *self.components.p4_port1_remote_port1_line.get_state_vector(), - *self.components.p4_port1_far_primary_line.get_state_vector(), - *self.components.p4_port1_far_primary_chamber.get_state_vector(), - *self.components.p4_port1_far_port1_line.get_state_vector(), - *self.components.p4_port1_next_primary_line.get_state_vector(), - *self.components.p4_port1_next_primary_chamber.get_state_vector(), - *self.components.p4_port1_next_port1_line.get_state_vector(), - *self.components.p4_bridge_primary_line.get_state_vector(), - *self.components.p4_bridge_primary_chamber.get_state_vector(), - *self.components.p4_port1_remote_orifice_line.get_state_vector(), - *self.components.p4_port3_line.get_state_vector(), - *self.components.p4_port3_remote_primary_line.get_state_vector(), - *self.components.p4_port3_remote_primary_chamber.get_state_vector(), - *self.components.p4_port3_remote_port3_line.get_state_vector(), - *self.components.p4_port3_far_primary_line.get_state_vector(), - *self.components.p4_port3_far_primary_chamber.get_state_vector(), - *self.components.p4_port3_far_port3_line.get_state_vector(), - *self.components.p4_port3_next_primary_line.get_state_vector(), - *self.components.p4_port3_next_primary_chamber.get_state_vector(), - *self.components.p4_port3_next_port3_line.get_state_vector(), - *self.components.p4_port3_remote_resistance_node_line.get_state_vector(), - *self.components.p4_port3_remote_resistance_output_line.get_state_vector(), - *self.components.p4_port3_remote_orifice_line.get_state_vector(), - *self.components.p4_port3_next_orifice_line.get_state_vector(), - *self.components.p4_port3_next_orifice_output_line.get_state_vector(), - *self.components.p4_port3_next_chamber_inlet_line.get_state_vector(), - *self.components.p4_port3_next_chamber.get_state_vector(), - *self.components.p4_port3_next_chamber_outlet_line.get_state_vector(), - *self.components.p4_port3_next_resistance_node_line.get_state_vector(), - *self.components.p4_port3_next_resistance_output_line.get_state_vector(), - *self.components.p4_port3_next_resistance_chamber_inlet_line.get_state_vector(), - *self.components.p4_port3_next_resistance_chamber.get_state_vector(), - *self.components.p4_port3_next_resistance_chamber_outlet_line.get_state_vector(), - *self.components.p4_port1_next_orifice_line.get_state_vector(), - *self.components.p4_port1_next_orifice_output_line.get_state_vector(), - *self.components.p4_port1_next_orifice_resistance_line.get_state_vector(), - *self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.get_state_vector(), - *self.components.p4_port1_next_orifice_resistance_chamber.get_state_vector(), - *self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.get_state_vector(), - ] - - def apply_state_vector(self, values: list[float]) -> None: - if len(values) != 112: - raise ValueError("PN3/P4 node chamber segment state vector requires one hundred twelve values") - self.components.inlet_line.set_state_vector(values[:2]) - self.components.volume.set_state_vector(values[2:4]) - self.components.outlet_line.set_state_vector(values[4:6]) - self.components.node_line.set_state_vector(values[6:10]) - self.components.p4_primary_line.set_state_vector(values[10:12]) - self.components.p4_primary_chamber.set_state_vector(values[12:14]) - self.components.p4_port1_line.set_state_vector(values[14:16]) - self.components.p4_port1_remote_primary_line.set_state_vector(values[16:18]) - self.components.p4_port1_remote_primary_chamber.set_state_vector(values[18:20]) - self.components.p4_port1_remote_port1_line.set_state_vector(values[20:22]) - self.components.p4_port1_far_primary_line.set_state_vector(values[22:24]) - self.components.p4_port1_far_primary_chamber.set_state_vector(values[24:26]) - self.components.p4_port1_far_port1_line.set_state_vector(values[26:28]) - self.components.p4_port1_next_primary_line.set_state_vector(values[28:30]) - self.components.p4_port1_next_primary_chamber.set_state_vector(values[30:32]) - self.components.p4_port1_next_port1_line.set_state_vector(values[32:34]) - self.components.p4_bridge_primary_line.set_state_vector(values[34:36]) - self.components.p4_bridge_primary_chamber.set_state_vector(values[36:38]) - self.components.p4_port1_remote_orifice_line.set_state_vector(values[38:42]) - self.components.p4_port3_line.set_state_vector(values[42:44]) - self.components.p4_port3_remote_primary_line.set_state_vector(values[44:46]) - self.components.p4_port3_remote_primary_chamber.set_state_vector(values[46:48]) - self.components.p4_port3_remote_port3_line.set_state_vector(values[48:50]) - self.components.p4_port3_far_primary_line.set_state_vector(values[50:52]) - self.components.p4_port3_far_primary_chamber.set_state_vector(values[52:54]) - self.components.p4_port3_far_port3_line.set_state_vector(values[54:56]) - self.components.p4_port3_next_primary_line.set_state_vector(values[56:58]) - self.components.p4_port3_next_primary_chamber.set_state_vector(values[58:60]) - self.components.p4_port3_next_port3_line.set_state_vector(values[60:62]) - self.components.p4_port3_remote_resistance_node_line.set_state_vector(values[62:66]) - self.components.p4_port3_remote_resistance_output_line.set_state_vector(values[66:68]) - self.components.p4_port3_remote_orifice_line.set_state_vector(values[68:72]) - self.components.p4_port3_next_orifice_line.set_state_vector(values[72:76]) - self.components.p4_port3_next_orifice_output_line.set_state_vector(values[76:78]) - self.components.p4_port3_next_chamber_inlet_line.set_state_vector(values[78:80]) - self.components.p4_port3_next_chamber.set_state_vector(values[80:82]) - self.components.p4_port3_next_chamber_outlet_line.set_state_vector(values[82:84]) - self.components.p4_port3_next_resistance_node_line.set_state_vector(values[84:88]) - self.components.p4_port3_next_resistance_output_line.set_state_vector(values[88:90]) - self.components.p4_port3_next_resistance_chamber_inlet_line.set_state_vector(values[90:92]) - self.components.p4_port3_next_resistance_chamber.set_state_vector(values[92:94]) - self.components.p4_port3_next_resistance_chamber_outlet_line.set_state_vector(values[94:96]) - self.components.p4_port1_next_orifice_line.set_state_vector(values[96:100]) - self.components.p4_port1_next_orifice_output_line.set_state_vector(values[100:102]) - self.components.p4_port1_next_orifice_resistance_line.set_state_vector(values[102:106]) - self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.set_state_vector(values[106:108]) - self.components.p4_port1_next_orifice_resistance_chamber.set_state_vector(values[108:110]) - self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.set_state_vector(values[110:]) - - def snapshot( - self, - state_vector: list[float] | None = None, - ) -> TestMqlPn3P4NodeChamberSegmentSnapshot: - if state_vector is not None: - self.apply_state_vector(state_vector) - inlet_line = self.components.inlet_line.properties() - chamber = self.components.volume.properties() - outlet_line = self.components.outlet_line.properties() - node_line_port_1 = self.components.node_line.properties_1() - node_line_port_2 = self.components.node_line.properties_2() - p4_primary_line = self.components.p4_primary_line.properties() - p4_primary_chamber = self.components.p4_primary_chamber.properties() - p4_port1_line = self.components.p4_port1_line.properties() - p4_port1_remote_primary_line = ( - self.components.p4_port1_remote_primary_line.properties() - ) - p4_port1_remote_primary_chamber = ( - self.components.p4_port1_remote_primary_chamber.properties() - ) - p4_port1_remote_port1_line = ( - self.components.p4_port1_remote_port1_line.properties() - ) - p4_port1_far_primary_line = self.components.p4_port1_far_primary_line.properties() - p4_port1_far_primary_chamber = ( - self.components.p4_port1_far_primary_chamber.properties() - ) - p4_port1_far_port1_line = self.components.p4_port1_far_port1_line.properties() - p4_port1_next_primary_line = self.components.p4_port1_next_primary_line.properties() - p4_port1_next_primary_chamber = ( - self.components.p4_port1_next_primary_chamber.properties() - ) - p4_port1_next_port1_line = self.components.p4_port1_next_port1_line.properties() - p4_port1_next_orifice_line_port_1 = ( - self.components.p4_port1_next_orifice_line.properties_1() - ) - p4_port1_next_orifice_line_port_2 = ( - self.components.p4_port1_next_orifice_line.properties_2() - ) - p4_port1_next_orifice_output_line = ( - self.components.p4_port1_next_orifice_output_line.properties() - ) - p4_port1_next_orifice_resistance_line_port_1 = ( - self.components.p4_port1_next_orifice_resistance_line.properties_1() - ) - p4_port1_next_orifice_resistance_line_port_2 = ( - self.components.p4_port1_next_orifice_resistance_line.properties_2() - ) - p4_port1_next_orifice_resistance_chamber_inlet_line = ( - self.components.p4_port1_next_orifice_resistance_chamber_inlet_line.properties() - ) - p4_port1_next_orifice_resistance_chamber = ( - self.components.p4_port1_next_orifice_resistance_chamber.properties() - ) - p4_port1_next_orifice_resistance_chamber_outlet_line = ( - self.components.p4_port1_next_orifice_resistance_chamber_outlet_line.properties() - ) - p4_bridge_primary_line = self.components.p4_bridge_primary_line.properties() - p4_bridge_primary_chamber = self.components.p4_bridge_primary_chamber.properties() - p4_port1_remote_orifice_line_port_1 = ( - self.components.p4_port1_remote_orifice_line.properties_1() - ) - p4_port1_remote_orifice_line_port_2 = ( - self.components.p4_port1_remote_orifice_line.properties_2() - ) - p4_port3_line = self.components.p4_port3_line.properties() - p4_port3_remote_primary_line = ( - self.components.p4_port3_remote_primary_line.properties() - ) - p4_port3_remote_primary_chamber = ( - self.components.p4_port3_remote_primary_chamber.properties() - ) - p4_port3_remote_port3_line = ( - self.components.p4_port3_remote_port3_line.properties() - ) - p4_port3_far_primary_line = self.components.p4_port3_far_primary_line.properties() - p4_port3_far_primary_chamber = ( - self.components.p4_port3_far_primary_chamber.properties() - ) - p4_port3_far_port3_line = self.components.p4_port3_far_port3_line.properties() - p4_port3_next_primary_line = self.components.p4_port3_next_primary_line.properties() - p4_port3_next_primary_chamber = ( - self.components.p4_port3_next_primary_chamber.properties() - ) - p4_port3_next_port3_line = self.components.p4_port3_next_port3_line.properties() - p4_port3_next_orifice_line_port_1 = ( - self.components.p4_port3_next_orifice_line.properties_1() - ) - p4_port3_next_orifice_line_port_2 = ( - self.components.p4_port3_next_orifice_line.properties_2() - ) - p4_port3_next_orifice_output_line = ( - self.components.p4_port3_next_orifice_output_line.properties() - ) - p4_port3_next_chamber_inlet_line = ( - self.components.p4_port3_next_chamber_inlet_line.properties() - ) - p4_port3_next_chamber = self.components.p4_port3_next_chamber.properties() - p4_port3_next_chamber_outlet_line = ( - self.components.p4_port3_next_chamber_outlet_line.properties() - ) - p4_port3_next_resistance_node_line_port_1 = ( - self.components.p4_port3_next_resistance_node_line.properties_1() - ) - p4_port3_next_resistance_node_line_port_2 = ( - self.components.p4_port3_next_resistance_node_line.properties_2() - ) - p4_port3_next_resistance_output_line = ( - self.components.p4_port3_next_resistance_output_line.properties() - ) - p4_port3_next_resistance_chamber_inlet_line = ( - self.components.p4_port3_next_resistance_chamber_inlet_line.properties() - ) - p4_port3_next_resistance_chamber = ( - self.components.p4_port3_next_resistance_chamber.properties() - ) - p4_port3_next_resistance_chamber_outlet_line = ( - self.components.p4_port3_next_resistance_chamber_outlet_line.properties() - ) - p4_port3_remote_orifice_line_port_1 = ( - self.components.p4_port3_remote_orifice_line.properties_1() - ) - p4_port3_remote_orifice_line_port_2 = ( - self.components.p4_port3_remote_orifice_line.properties_2() - ) - p4_port3_remote_resistance_node_line_port_1 = ( - self.components.p4_port3_remote_resistance_node_line.properties_1() - ) - p4_port3_remote_resistance_node_line_port_2 = ( - self.components.p4_port3_remote_resistance_node_line.properties_2() - ) - p4_port3_remote_resistance_output_line = ( - self.components.p4_port3_remote_resistance_output_line.properties() - ) - inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) - resistance_boundary = self.resistance_boundary.properties( - self.components.node_resistance.gas - ) - chamber_segment_flows = self._fixed_chamber_segment_flows( - chamber_properties=chamber, - inlet_line=self.components.inlet_line, - inlet_line_properties=inlet_line, - inlet_node_properties=p4_port3_remote_orifice_line_port_1, - inlet_orifice=self.components.inlet_orifice, - outlet_line=self.components.outlet_line, - outlet_line_properties=outlet_line, - outlet_node_properties=node_line_port_1, - outlet_orifice=self.components.outlet_orifice, - ) - inlet_node_to_line_flow = chamber_segment_flows.inlet_node_to_line_flow - inlet_line_to_chamber_flow = chamber_segment_flows.inlet_line_to_chamber_flow - outlet_node_to_line_flow = chamber_segment_flows.outlet_node_to_line_flow - outlet_line_to_chamber_flow = chamber_segment_flows.outlet_line_to_chamber_flow - node_to_resistance_flow = self._resistance_flow( - resistance=self.components.node_resistance, - port_1_properties=node_line_port_1, - port_2_properties=p4_port1_remote_orifice_line_port_1, - ) - node_balance = self._pn3_node_balance( - node=self.components.node, - primary_properties=node_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=node_to_resistance_flow, - node_h=node_line_port_1.h, - connected_h=p4_port1_remote_orifice_line_port_1.h, - port_mass_flow_kg_s=node_to_resistance_flow, - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=outlet_node_to_line_flow, - node_h=node_line_port_1.h, - connected_h=outlet_line.h, - port_mass_flow_kg_s=outlet_node_to_line_flow, - ), - ) - node_to_pnl0003_flow = -node_balance.port_2_mass_flow_g_s * 1.0e-3 - pnl0003_center_flow = self.components.node_line.resistance_mass_flow() - pnl0003_to_p4_flow = self._line_to_p4_flow( - line=node_line_port_2, - p4_boundary=p4_primary_line, - orifice=self.components.node_orifice, - ) - - p4_node_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_primary_line, - primary_chamber_properties=p4_primary_chamber, - port_1_line=self.components.p4_port1_line, - port_1_properties=p4_primary_line, - port_3_line=self.components.p4_port3_line, - port_3_properties=p4_primary_line, - ) - p4_primary_chamber_to_line_flow = ( - p4_node_line_flows.primary_chamber_to_line_flow - ) - p4_to_port1_line_flow = p4_node_line_flows.port_1_line_flow - p4_to_port3_line_flow = p4_node_line_flows.port_3_line_flow - p4_port1_remote_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_port1_remote_primary_line, - primary_chamber_properties=p4_port1_remote_primary_chamber, - port_1_line=self.components.p4_port1_remote_port1_line, - port_1_properties=p4_port1_remote_primary_line, - port_3_line=self.components.p4_port1_line, - port_3_properties=p4_port1_remote_primary_line, - ) - p4_port1_remote_chamber_to_line_flow = ( - p4_port1_remote_line_flows.primary_chamber_to_line_flow - ) - p4_port1_remote_to_port1_line_flow = ( - p4_port1_remote_line_flows.port_1_line_flow - ) - p4_port1_remote_node_to_line_flow = ( - p4_port1_remote_line_flows.port_3_line_flow - ) - p4_port1_far_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_port1_far_primary_line, - primary_chamber_properties=p4_port1_far_primary_chamber, - port_1_line=self.components.p4_port1_far_port1_line, - port_1_properties=p4_port1_far_primary_line, - port_3_line=self.components.p4_port1_remote_port1_line, - port_3_properties=p4_port1_far_primary_line, - ) - p4_port1_far_chamber_to_line_flow = ( - p4_port1_far_line_flows.primary_chamber_to_line_flow - ) - p4_port1_far_to_next_line_flow = p4_port1_far_line_flows.port_1_line_flow - p4_port1_far_node_to_line_flow = p4_port1_far_line_flows.port_3_line_flow - p4_port1_next_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_port1_next_primary_line, - primary_chamber_properties=p4_port1_next_primary_chamber, - port_1_line=self.components.p4_port1_next_port1_line, - port_1_properties=p4_port1_next_primary_line, - port_3_line=self.components.p4_port1_far_port1_line, - port_3_properties=p4_port1_next_primary_line, - ) - p4_port1_next_chamber_to_line_flow = ( - p4_port1_next_line_flows.primary_chamber_to_line_flow - ) - p4_port1_next_to_bridge_line_flow = ( - p4_port1_next_line_flows.port_1_line_flow - ) - p4_port1_next_node_to_line_flow = p4_port1_next_line_flows.port_3_line_flow - p4_port1_next_orifice_p4_to_output_line_flow = ( - self.components.p4_port1_next_orifice_output_line.resistance_mass_flow( - port_1_pressure_pa=p4_port1_next_primary_line.p, - port_1_temperature_k=p4_port1_next_primary_line.T, - ) - ) - p4_bridge_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_bridge_primary_line, - primary_chamber_properties=p4_bridge_primary_chamber, - port_1_line=self.components.p4_port3_next_port3_line, - port_1_properties=p4_bridge_primary_line, - port_3_line=self.components.p4_port1_next_port1_line, - port_3_properties=p4_bridge_primary_line, - ) - p4_bridge_chamber_to_line_flow = ( - p4_bridge_line_flows.primary_chamber_to_line_flow - ) - p4_bridge_to_port3_next_line_flow = p4_bridge_line_flows.port_1_line_flow - p4_bridge_to_port1_next_line_flow = p4_bridge_line_flows.port_3_line_flow - p4_port1_next_orifice_resistance_chamber_flows = ( - self._fixed_chamber_segment_flows( - chamber_properties=p4_port1_next_orifice_resistance_chamber, - inlet_line=( - self.components.p4_port1_next_orifice_resistance_chamber_inlet_line - ), - inlet_line_properties=( - p4_port1_next_orifice_resistance_chamber_inlet_line - ), - inlet_node_properties=p4_port1_remote_orifice_line_port_1, - inlet_orifice=( - self.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice - ), - outlet_line=( - self.components.p4_port1_next_orifice_resistance_chamber_outlet_line - ), - outlet_line_properties=( - p4_port1_next_orifice_resistance_chamber_outlet_line - ), - outlet_node_properties=p4_port1_next_orifice_resistance_line_port_1, - outlet_orifice=( - self.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice - ), - ) - ) - p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow = ( - p4_port1_next_orifice_resistance_chamber_flows.inlet_node_to_line_flow - ) - p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow = ( - p4_port1_next_orifice_resistance_chamber_flows.inlet_line_to_chamber_flow - ) - p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow = ( - p4_port1_next_orifice_resistance_chamber_flows.outlet_node_to_line_flow - ) - p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow = ( - p4_port1_next_orifice_resistance_chamber_flows.outlet_line_to_chamber_flow - ) - p4_port1_remote_orifice_node_balance = self._pn3_node_balance( - node=self.components.p4_port1_remote_orifice_node, - primary_properties=p4_port1_remote_orifice_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=( - p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow - ), - node_h=p4_port1_remote_orifice_line_port_1.h, - connected_h=p4_port1_next_orifice_resistance_chamber_inlet_line.h, - port_mass_flow_kg_s=( - p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow - ), - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=-node_to_resistance_flow, - node_h=p4_port1_remote_orifice_line_port_1.h, - connected_h=node_line_port_1.h, - port_mass_flow_kg_s=-node_to_resistance_flow, - ), - ) - p4_port1_remote_node_to_orifice_line_flow = ( - -p4_port1_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port1_remote_orifice_line_flows = self._two_state_orifice_line_flows( - line=self.components.p4_port1_remote_orifice_line, - line_port_2_properties=p4_port1_remote_orifice_line_port_2, - boundary_properties=p4_port1_remote_primary_line, - orifice=self.components.p4_port1_remote_orifice, - ) - p4_port1_remote_orifice_line_center_flow = ( - p4_port1_remote_orifice_line_flows.line_center_flow - ) - p4_port1_remote_orifice_to_node_flow = ( - p4_port1_remote_orifice_line_flows.line_to_boundary_flow - ) - p4_port3_remote_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_port3_remote_primary_line, - primary_chamber_properties=p4_port3_remote_primary_chamber, - port_1_line=self.components.p4_port3_line, - port_1_properties=p4_port3_remote_primary_line, - port_3_line=self.components.p4_port3_remote_port3_line, - port_3_properties=p4_port3_remote_primary_line, - ) - p4_port3_remote_chamber_to_line_flow = ( - p4_port3_remote_line_flows.primary_chamber_to_line_flow - ) - p4_port3_remote_node_to_line_flow = ( - p4_port3_remote_line_flows.port_1_line_flow - ) - p4_port3_remote_to_port3_line_flow = ( - p4_port3_remote_line_flows.port_3_line_flow - ) - p4_port3_far_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_port3_far_primary_line, - primary_chamber_properties=p4_port3_far_primary_chamber, - port_1_line=self.components.p4_port3_remote_port3_line, - port_1_properties=p4_port3_far_primary_line, - port_3_line=self.components.p4_port3_far_port3_line, - port_3_properties=p4_port3_far_primary_line, - ) - p4_port3_far_chamber_to_line_flow = ( - p4_port3_far_line_flows.primary_chamber_to_line_flow - ) - p4_port3_far_node_to_line_flow = p4_port3_far_line_flows.port_1_line_flow - p4_port3_far_to_next_line_flow = p4_port3_far_line_flows.port_3_line_flow - p4_port3_next_line_flows = self._p4_neighborhood_line_flows( - primary_line=self.components.p4_port3_next_primary_line, - primary_chamber_properties=p4_port3_next_primary_chamber, - port_1_line=self.components.p4_port3_far_port3_line, - port_1_properties=p4_port3_next_primary_line, - port_3_line=self.components.p4_port3_next_port3_line, - port_3_properties=p4_port3_next_primary_line, - ) - p4_port3_next_chamber_to_line_flow = ( - p4_port3_next_line_flows.primary_chamber_to_line_flow - ) - p4_port3_next_node_to_line_flow = p4_port3_next_line_flows.port_1_line_flow - p4_port3_next_to_bridge_line_flow = ( - p4_port3_next_line_flows.port_3_line_flow - ) - p4_port3_next_chamber_flows = self._fixed_chamber_segment_flows( - chamber_properties=p4_port3_next_chamber, - inlet_line=self.components.p4_port3_next_chamber_inlet_line, - inlet_line_properties=p4_port3_next_chamber_inlet_line, - inlet_node_properties=p4_port3_next_orifice_line_port_1, - inlet_orifice=self.components.p4_port3_next_chamber_inlet_orifice, - outlet_line=self.components.p4_port3_next_chamber_outlet_line, - outlet_line_properties=p4_port3_next_chamber_outlet_line, - outlet_node_properties=p4_port3_remote_resistance_node_line_port_1, - outlet_orifice=self.components.p4_port3_next_chamber_outlet_orifice, - ) - p4_port3_next_chamber_inlet_node_to_line_flow = ( - p4_port3_next_chamber_flows.inlet_node_to_line_flow - ) - p4_port3_next_chamber_inlet_line_to_chamber_flow = ( - p4_port3_next_chamber_flows.inlet_line_to_chamber_flow - ) - p4_port3_next_chamber_outlet_node_to_line_flow = ( - p4_port3_next_chamber_flows.outlet_node_to_line_flow - ) - p4_port3_next_chamber_outlet_line_to_chamber_flow = ( - p4_port3_next_chamber_flows.outlet_line_to_chamber_flow - ) - p4_port3_next_resistance_node_to_next_flow = self._resistance_flow( - resistance=self.components.p4_port3_next_orifice_resistance, - port_1_properties=p4_port3_next_resistance_node_line_port_1, - port_2_properties=p4_port3_next_orifice_line_port_1, - ) - p4_port3_next_orifice_node_balance = self._pn3_node_balance( - node=self.components.p4_port3_next_orifice_node, - primary_properties=p4_port3_next_orifice_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=p4_port3_next_chamber_inlet_node_to_line_flow, - node_h=p4_port3_next_orifice_line_port_1.h, - connected_h=p4_port3_next_chamber_inlet_line.h, - port_mass_flow_kg_s=p4_port3_next_chamber_inlet_node_to_line_flow, - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=-p4_port3_next_resistance_node_to_next_flow, - node_h=p4_port3_next_orifice_line_port_1.h, - connected_h=p4_port3_next_resistance_node_line_port_1.h, - port_mass_flow_kg_s=-p4_port3_next_resistance_node_to_next_flow, - ), - ) - p4_port3_next_orifice_node_to_line_flow = ( - -p4_port3_next_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port3_next_orifice_line_flows = self._two_state_orifice_line_flows( - line=self.components.p4_port3_next_orifice_line, - line_port_2_properties=p4_port3_next_orifice_line_port_2, - boundary_properties=p4_port3_next_orifice_output_line, - orifice=self.components.p4_port3_next_orifice, - ) - p4_port3_next_orifice_line_center_flow = ( - p4_port3_next_orifice_line_flows.line_center_flow - ) - p4_port3_next_orifice_to_output_line_flow = ( - p4_port3_next_orifice_line_flows.line_to_boundary_flow - ) - p4_port3_next_orifice_output_to_p4_flow = ( - p4_port3_next_orifice_to_output_line_flow - ) - p4_port3_next_resistance_chamber_flows = ( - self._fixed_chamber_segment_flows( - chamber_properties=p4_port3_next_resistance_chamber, - inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, - inlet_line_properties=p4_port3_next_resistance_chamber_inlet_line, - inlet_node_properties=p4_port1_next_orifice_line_port_1, - inlet_orifice=( - self.components.p4_port3_next_resistance_chamber_inlet_orifice - ), - outlet_line=( - self.components.p4_port3_next_resistance_chamber_outlet_line - ), - outlet_line_properties=p4_port3_next_resistance_chamber_outlet_line, - outlet_node_properties=p4_port3_next_resistance_node_line_port_1, - outlet_orifice=( - self.components.p4_port3_next_resistance_chamber_outlet_orifice - ), - ) - ) - p4_port3_next_resistance_chamber_inlet_node_to_line_flow = ( - p4_port3_next_resistance_chamber_flows.inlet_node_to_line_flow - ) - p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow = ( - p4_port3_next_resistance_chamber_flows.inlet_line_to_chamber_flow - ) - p4_port3_next_resistance_chamber_outlet_node_to_line_flow = ( - p4_port3_next_resistance_chamber_flows.outlet_node_to_line_flow - ) - p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow = ( - p4_port3_next_resistance_chamber_flows.outlet_line_to_chamber_flow - ) - p4_port1_next_orifice_resistance_node_to_next_flow = self._resistance_flow( - resistance=self.components.p4_port1_next_orifice_resistance, - port_1_properties=p4_port1_next_orifice_resistance_line_port_1, - port_2_properties=p4_port1_next_orifice_line_port_1, - ) - p4_port1_next_orifice_node_balance = self._pn3_node_balance( - node=self.components.p4_port1_next_orifice_node, - primary_properties=p4_port1_next_orifice_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=p4_port3_next_resistance_chamber_inlet_node_to_line_flow, - node_h=p4_port1_next_orifice_line_port_1.h, - connected_h=p4_port3_next_resistance_chamber_inlet_line.h, - port_mass_flow_kg_s=( - p4_port3_next_resistance_chamber_inlet_node_to_line_flow - ), - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=-p4_port1_next_orifice_resistance_node_to_next_flow, - node_h=p4_port1_next_orifice_line_port_1.h, - connected_h=p4_port1_next_orifice_resistance_line_port_1.h, - port_mass_flow_kg_s=( - -p4_port1_next_orifice_resistance_node_to_next_flow - ), - ), - ) - p4_port1_next_orifice_node_to_line_flow = ( - -p4_port1_next_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port1_next_orifice_line_flows = self._two_state_orifice_line_flows( - line=self.components.p4_port1_next_orifice_line, - line_port_2_properties=p4_port1_next_orifice_line_port_2, - boundary_properties=p4_port1_next_orifice_output_line, - orifice=self.components.p4_port1_next_orifice, - ) - p4_port1_next_orifice_line_center_flow = ( - p4_port1_next_orifice_line_flows.line_center_flow - ) - p4_port1_next_orifice_to_output_line_flow = ( - p4_port1_next_orifice_line_flows.line_to_boundary_flow - ) - p4_port1_next_orifice_resistance_node_balance = self._pn3_node_balance( - node=self.components.p4_port1_next_orifice_resistance_node, - primary_properties=p4_port1_next_orifice_resistance_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=p4_port1_next_orifice_resistance_node_to_next_flow, - node_h=p4_port1_next_orifice_resistance_line_port_1.h, - connected_h=p4_port1_next_orifice_line_port_1.h, - port_mass_flow_kg_s=p4_port1_next_orifice_resistance_node_to_next_flow, - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=( - p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow - ), - node_h=p4_port1_next_orifice_resistance_line_port_1.h, - connected_h=p4_port1_next_orifice_resistance_chamber_outlet_line.h, - port_mass_flow_kg_s=( - p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow - ), - ), - ) - p4_port1_next_orifice_resistance_node_to_line_flow = ( - -p4_port1_next_orifice_resistance_node_balance.port_2_mass_flow_g_s - * 1.0e-3 - ) - p4_port1_next_orifice_resistance_line_flows = ( - self._two_state_orifice_line_flows( - line=self.components.p4_port1_next_orifice_resistance_line, - line_port_2_properties=( - p4_port1_next_orifice_resistance_line_port_2 - ), - boundary_properties=p4_port1_far_primary_line, - orifice=self.components.p4_port1_next_orifice_resistance_orifice, - ) - ) - p4_port1_next_orifice_resistance_line_center_flow = ( - p4_port1_next_orifice_resistance_line_flows.line_center_flow - ) - p4_port1_next_orifice_resistance_to_p4_flow = ( - p4_port1_next_orifice_resistance_line_flows.line_to_boundary_flow - ) - p4_port3_next_resistance_node_balance = self._pn3_node_balance( - node=self.components.p4_port3_next_resistance_node, - primary_properties=p4_port3_next_resistance_node_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=p4_port3_next_resistance_node_to_next_flow, - node_h=p4_port3_next_resistance_node_line_port_1.h, - connected_h=p4_port3_next_orifice_line_port_1.h, - port_mass_flow_kg_s=p4_port3_next_resistance_node_to_next_flow, - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=p4_port3_next_resistance_chamber_outlet_node_to_line_flow, - node_h=p4_port3_next_resistance_node_line_port_1.h, - connected_h=p4_port3_next_resistance_chamber_outlet_line.h, - port_mass_flow_kg_s=( - p4_port3_next_resistance_chamber_outlet_node_to_line_flow - ), - ), - ) - p4_port3_next_resistance_node_to_line_flow = ( - -p4_port3_next_resistance_node_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port3_next_resistance_line_flows = self._two_state_orifice_line_flows( - line=self.components.p4_port3_next_resistance_node_line, - line_port_2_properties=p4_port3_next_resistance_node_line_port_2, - boundary_properties=p4_port3_next_resistance_output_line, - orifice=self.components.p4_port3_next_resistance_orifice, - ) - p4_port3_next_resistance_line_center_flow = ( - p4_port3_next_resistance_line_flows.line_center_flow - ) - p4_port3_next_resistance_to_output_line_flow = ( - p4_port3_next_resistance_line_flows.line_to_boundary_flow - ) - p4_port3_next_resistance_output_to_p4_flow = ( - p4_port3_next_resistance_to_output_line_flow - ) - p4_port3_remote_node_to_resistance_flow = self._resistance_flow( - resistance=self.components.p4_port3_remote_orifice_resistance, - port_1_properties=p4_port3_remote_orifice_line_port_1, - port_2_properties=p4_port3_remote_resistance_node_line_port_1, - ) - p4_port3_remote_orifice_node_balance = self._pn3_node_balance( - node=self.components.p4_port3_remote_orifice_node, - primary_properties=p4_port3_remote_orifice_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=inlet_node_to_line_flow, - node_h=p4_port3_remote_orifice_line_port_1.h, - connected_h=inlet_line.h, - port_mass_flow_kg_s=inlet_node_to_line_flow, - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=p4_port3_remote_node_to_resistance_flow, - node_h=p4_port3_remote_orifice_line_port_1.h, - connected_h=p4_port3_remote_resistance_node_line_port_1.h, - port_mass_flow_kg_s=p4_port3_remote_node_to_resistance_flow, - ), - ) - p4_port3_remote_node_to_orifice_line_flow = ( - -p4_port3_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port3_remote_resistance_node_balance = self._pn3_node_balance( - node=self.components.p4_port3_remote_resistance_node, - primary_properties=p4_port3_remote_resistance_node_line_port_1, - port_1=TestMqlPn3NodeBalancePort( - flow_kg_s=-p4_port3_remote_node_to_resistance_flow, - node_h=p4_port3_remote_resistance_node_line_port_1.h, - connected_h=p4_port3_remote_orifice_line_port_1.h, - port_mass_flow_kg_s=-p4_port3_remote_node_to_resistance_flow, - ), - port_3=TestMqlPn3NodeBalancePort( - flow_kg_s=p4_port3_next_chamber_outlet_node_to_line_flow, - node_h=p4_port3_remote_resistance_node_line_port_1.h, - connected_h=p4_port3_next_chamber_outlet_line.h, - port_mass_flow_kg_s=p4_port3_next_chamber_outlet_node_to_line_flow, - ), - ) - p4_port3_remote_resistance_node_to_line_flow = ( - -p4_port3_remote_resistance_node_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port3_remote_resistance_line_flows = self._two_state_orifice_line_flows( - line=self.components.p4_port3_remote_resistance_node_line, - line_port_2_properties=p4_port3_remote_resistance_node_line_port_2, - boundary_properties=p4_port3_remote_resistance_output_line, - orifice=self.components.p4_port3_remote_resistance_orifice, - ) - p4_port3_remote_resistance_line_center_flow = ( - p4_port3_remote_resistance_line_flows.line_center_flow - ) - p4_port3_remote_resistance_to_output_line_flow = ( - p4_port3_remote_resistance_line_flows.line_to_boundary_flow - ) - p4_port3_remote_resistance_output_to_p4_flow = ( - p4_port3_remote_resistance_to_output_line_flow - ) - p4_port3_remote_orifice_line_flows = self._two_state_orifice_line_flows( - line=self.components.p4_port3_remote_orifice_line, - line_port_2_properties=p4_port3_remote_orifice_line_port_2, - boundary_properties=p4_port3_remote_primary_line, - orifice=self.components.p4_port3_remote_orifice, - ) - p4_port3_remote_orifice_line_center_flow = ( - p4_port3_remote_orifice_line_flows.line_center_flow - ) - p4_port3_remote_orifice_to_node_flow = ( - p4_port3_remote_orifice_line_flows.line_to_boundary_flow - ) - p4_balance = self._p4_node_balance( - node=self.components.p4_node, - primary_properties=p4_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_to_port1_line_flow, - node_h=p4_primary_line.h, - connected_h=p4_port1_line.h, - port_mass_flow_kg_s=-p4_to_port1_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_to_port3_line_flow, - node_h=p4_primary_line.h, - connected_h=p4_port3_line.h, - port_mass_flow_kg_s=-p4_to_port3_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=pnl0003_to_p4_flow, - node_h=node_line_port_2.h, - connected_h=p4_primary_line.h, - port_mass_flow_kg_s=pnl0003_to_p4_flow, - ), - ) - p4_node_to_primary_line_flow = p4_balance.port_2_mass_flow_g_s * 1.0e-3 - p4_port1_remote_balance = self._p4_node_balance( - node=self.components.p4_port1_remote_node, - primary_properties=p4_port1_remote_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_remote_to_port1_line_flow, - node_h=p4_port1_remote_primary_line.h, - connected_h=p4_port1_remote_port1_line.h, - port_mass_flow_kg_s=-p4_port1_remote_to_port1_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_remote_node_to_line_flow, - node_h=p4_port1_remote_primary_line.h, - connected_h=p4_port1_line.h, - port_mass_flow_kg_s=-p4_port1_remote_node_to_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_remote_orifice_to_node_flow, - node_h=p4_port1_remote_orifice_line_port_2.h, - connected_h=p4_port1_remote_primary_line.h, - port_mass_flow_kg_s=p4_port1_remote_orifice_to_node_flow, - ), - ) - p4_port1_remote_node_to_primary_line_flow = ( - p4_port1_remote_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port1_far_balance = self._p4_node_balance( - node=self.components.p4_port1_far_node, - primary_properties=p4_port1_far_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_far_to_next_line_flow, - node_h=p4_port1_far_primary_line.h, - connected_h=p4_port1_far_port1_line.h, - port_mass_flow_kg_s=-p4_port1_far_to_next_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_far_node_to_line_flow, - node_h=p4_port1_far_primary_line.h, - connected_h=p4_port1_remote_port1_line.h, - port_mass_flow_kg_s=-p4_port1_far_node_to_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_next_orifice_resistance_to_p4_flow, - node_h=p4_port1_next_orifice_resistance_line_port_2.h, - connected_h=p4_port1_far_primary_line.h, - port_mass_flow_kg_s=p4_port1_next_orifice_resistance_to_p4_flow, - ), - ) - p4_port1_far_node_to_primary_line_flow = ( - p4_port1_far_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port1_next_balance = self._p4_node_balance( - node=self.components.p4_port1_next_node, - primary_properties=p4_port1_next_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_next_to_bridge_line_flow, - node_h=p4_port1_next_primary_line.h, - connected_h=p4_port1_next_port1_line.h, - port_mass_flow_kg_s=-p4_port1_next_to_bridge_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_next_node_to_line_flow, - node_h=p4_port1_next_primary_line.h, - connected_h=p4_port1_far_port1_line.h, - port_mass_flow_kg_s=-p4_port1_next_node_to_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port1_next_orifice_p4_to_output_line_flow, - node_h=p4_port1_next_primary_line.h, - connected_h=p4_port1_next_orifice_output_line.h, - port_mass_flow_kg_s=-p4_port1_next_orifice_p4_to_output_line_flow, - ), - ) - p4_port1_next_node_to_primary_line_flow = ( - p4_port1_next_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port3_remote_balance = self._p4_node_balance( - node=self.components.p4_port3_remote_node, - primary_properties=p4_port3_remote_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_remote_node_to_line_flow, - node_h=p4_port3_remote_primary_line.h, - connected_h=p4_port3_line.h, - port_mass_flow_kg_s=-p4_port3_remote_node_to_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_remote_to_port3_line_flow, - node_h=p4_port3_remote_primary_line.h, - connected_h=p4_port3_remote_port3_line.h, - port_mass_flow_kg_s=-p4_port3_remote_to_port3_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_remote_orifice_to_node_flow, - node_h=p4_port3_remote_orifice_line_port_2.h, - connected_h=p4_port3_remote_primary_line.h, - port_mass_flow_kg_s=p4_port3_remote_orifice_to_node_flow, - ), - ) - p4_port3_remote_node_to_primary_line_flow = ( - p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port3_far_balance = self._p4_node_balance( - node=self.components.p4_port3_far_node, - primary_properties=p4_port3_far_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_far_node_to_line_flow, - node_h=p4_port3_far_primary_line.h, - connected_h=p4_port3_remote_port3_line.h, - port_mass_flow_kg_s=-p4_port3_far_node_to_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_far_to_next_line_flow, - node_h=p4_port3_far_primary_line.h, - connected_h=p4_port3_far_port3_line.h, - port_mass_flow_kg_s=-p4_port3_far_to_next_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_remote_resistance_output_to_p4_flow, - node_h=p4_port3_remote_resistance_output_line.h, - connected_h=p4_port3_far_primary_line.h, - port_mass_flow_kg_s=p4_port3_remote_resistance_output_to_p4_flow, - ), - ) - p4_port3_far_node_to_primary_line_flow = ( - p4_port3_far_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_port3_next_balance = self._p4_node_balance( - node=self.components.p4_port3_next_node, - primary_properties=p4_port3_next_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_next_node_to_line_flow, - node_h=p4_port3_next_primary_line.h, - connected_h=p4_port3_far_port3_line.h, - port_mass_flow_kg_s=-p4_port3_next_node_to_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_next_to_bridge_line_flow, - node_h=p4_port3_next_primary_line.h, - connected_h=p4_port3_next_port3_line.h, - port_mass_flow_kg_s=-p4_port3_next_to_bridge_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_next_orifice_output_to_p4_flow, - node_h=p4_port3_next_orifice_output_line.h, - connected_h=p4_port3_next_primary_line.h, - port_mass_flow_kg_s=p4_port3_next_orifice_output_to_p4_flow, - ), - ) - p4_port3_next_node_to_primary_line_flow = ( - p4_port3_next_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - p4_bridge_balance = self._p4_node_balance( - node=self.components.p4_bridge_node, - primary_properties=p4_bridge_primary_line, - port_1=TestMqlP4NodeBalancePort( - flow_kg_s=p4_bridge_to_port3_next_line_flow, - node_h=p4_bridge_primary_line.h, - connected_h=p4_port3_next_port3_line.h, - port_mass_flow_kg_s=-p4_bridge_to_port3_next_line_flow, - ), - port_3=TestMqlP4NodeBalancePort( - flow_kg_s=p4_bridge_to_port1_next_line_flow, - node_h=p4_bridge_primary_line.h, - connected_h=p4_port1_next_port1_line.h, - port_mass_flow_kg_s=-p4_bridge_to_port1_next_line_flow, - ), - port_4=TestMqlP4NodeBalancePort( - flow_kg_s=p4_port3_next_resistance_output_to_p4_flow, - node_h=p4_port3_next_resistance_output_line.h, - connected_h=p4_bridge_primary_line.h, - port_mass_flow_kg_s=p4_port3_next_resistance_output_to_p4_flow, - ), - ) - p4_bridge_node_to_primary_line_flow = ( - p4_bridge_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - - snapshot = TestMqlPn3P4NodeChamberSegmentSnapshot( - inlet_line=inlet_line, - chamber=chamber, - outlet_line=outlet_line, - node_line_port_1=node_line_port_1, - node_line_port_2=node_line_port_2, - p4_primary_line=p4_primary_line, - p4_boundary=p4_primary_line, - p4_primary_chamber=p4_primary_chamber, - p4_port1_line=p4_port1_line, - p4_port1_remote_primary_line=p4_port1_remote_primary_line, - p4_port1_remote_primary_chamber=p4_port1_remote_primary_chamber, - p4_port1_remote_port1_line=p4_port1_remote_port1_line, - p4_port1_far_primary_line=p4_port1_far_primary_line, - p4_port1_far_primary_chamber=p4_port1_far_primary_chamber, - p4_port1_far_port1_line=p4_port1_far_port1_line, - p4_port1_next_primary_line=p4_port1_next_primary_line, - p4_port1_next_primary_chamber=p4_port1_next_primary_chamber, - p4_port1_next_port1_line=p4_port1_next_port1_line, - p4_port1_next_orifice_line_port_1=p4_port1_next_orifice_line_port_1, - p4_port1_next_orifice_line_port_2=p4_port1_next_orifice_line_port_2, - p4_port1_next_orifice_output_line=p4_port1_next_orifice_output_line, - p4_port1_next_orifice_resistance_line_port_1=( - p4_port1_next_orifice_resistance_line_port_1 - ), - p4_port1_next_orifice_resistance_line_port_2=( - p4_port1_next_orifice_resistance_line_port_2 - ), - p4_port1_next_orifice_resistance_chamber_inlet_line=( - p4_port1_next_orifice_resistance_chamber_inlet_line - ), - p4_port1_next_orifice_resistance_chamber=( - p4_port1_next_orifice_resistance_chamber - ), - p4_port1_next_orifice_resistance_chamber_outlet_line=( - p4_port1_next_orifice_resistance_chamber_outlet_line - ), - p4_bridge_primary_line=p4_bridge_primary_line, - p4_bridge_primary_chamber=p4_bridge_primary_chamber, - p4_port1_remote_orifice_line_port_1=p4_port1_remote_orifice_line_port_1, - p4_port1_remote_orifice_line_port_2=p4_port1_remote_orifice_line_port_2, - p4_port3_line=p4_port3_line, - p4_port3_remote_primary_line=p4_port3_remote_primary_line, - p4_port3_remote_primary_chamber=p4_port3_remote_primary_chamber, - p4_port3_remote_port3_line=p4_port3_remote_port3_line, - p4_port3_far_primary_line=p4_port3_far_primary_line, - p4_port3_far_primary_chamber=p4_port3_far_primary_chamber, - p4_port3_far_port3_line=p4_port3_far_port3_line, - p4_port3_next_primary_line=p4_port3_next_primary_line, - p4_port3_next_primary_chamber=p4_port3_next_primary_chamber, - p4_port3_next_port3_line=p4_port3_next_port3_line, - p4_port3_next_orifice_line_port_1=p4_port3_next_orifice_line_port_1, - p4_port3_next_orifice_line_port_2=p4_port3_next_orifice_line_port_2, - p4_port3_next_orifice_output_line=p4_port3_next_orifice_output_line, - p4_port3_next_chamber_inlet_line=p4_port3_next_chamber_inlet_line, - p4_port3_next_chamber=p4_port3_next_chamber, - p4_port3_next_chamber_outlet_line=p4_port3_next_chamber_outlet_line, - p4_port3_next_resistance_node_line_port_1=( - p4_port3_next_resistance_node_line_port_1 - ), - p4_port3_next_resistance_node_line_port_2=( - p4_port3_next_resistance_node_line_port_2 - ), - p4_port3_next_resistance_output_line=p4_port3_next_resistance_output_line, - p4_port3_next_resistance_chamber_inlet_line=( - p4_port3_next_resistance_chamber_inlet_line - ), - p4_port3_next_resistance_chamber=p4_port3_next_resistance_chamber, - p4_port3_next_resistance_chamber_outlet_line=( - p4_port3_next_resistance_chamber_outlet_line - ), - p4_port3_remote_orifice_line_port_1=p4_port3_remote_orifice_line_port_1, - p4_port3_remote_orifice_line_port_2=p4_port3_remote_orifice_line_port_2, - p4_port3_remote_resistance_node_line_port_1=( - p4_port3_remote_resistance_node_line_port_1 - ), - p4_port3_remote_resistance_node_line_port_2=( - p4_port3_remote_resistance_node_line_port_2 - ), - p4_port3_remote_resistance_output_line=( - p4_port3_remote_resistance_output_line - ), - inlet_node=inlet_node, - resistance_boundary=resistance_boundary, - node_balance=node_balance, - p4_balance=p4_balance, - p4_port1_remote_balance=p4_port1_remote_balance, - p4_port1_far_balance=p4_port1_far_balance, - p4_port1_next_balance=p4_port1_next_balance, - p4_bridge_balance=p4_bridge_balance, - p4_port1_remote_orifice_node_balance=p4_port1_remote_orifice_node_balance, - p4_port1_next_orifice_node_balance=p4_port1_next_orifice_node_balance, - p4_port1_next_orifice_resistance_node_balance=( - p4_port1_next_orifice_resistance_node_balance - ), - p4_port3_remote_balance=p4_port3_remote_balance, - p4_port3_far_balance=p4_port3_far_balance, - p4_port3_next_balance=p4_port3_next_balance, - p4_port3_next_orifice_node_balance=p4_port3_next_orifice_node_balance, - p4_port3_next_resistance_node_balance=p4_port3_next_resistance_node_balance, - p4_port3_remote_orifice_node_balance=p4_port3_remote_orifice_node_balance, - p4_port3_remote_resistance_node_balance=( - p4_port3_remote_resistance_node_balance - ), - inlet_node_to_line_flow=inlet_node_to_line_flow, - inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, - outlet_node_to_line_flow=outlet_node_to_line_flow, - outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, - node_to_resistance_flow=node_to_resistance_flow, - node_to_pnl0003_flow=node_to_pnl0003_flow, - pnl0003_center_flow=pnl0003_center_flow, - pnl0003_to_p4_flow=pnl0003_to_p4_flow, - p4_primary_chamber_to_line_flow=p4_primary_chamber_to_line_flow, - p4_node_to_primary_line_flow=p4_node_to_primary_line_flow, - p4_to_port1_line_flow=p4_to_port1_line_flow, - p4_port1_remote_node_to_line_flow=p4_port1_remote_node_to_line_flow, - p4_port1_remote_chamber_to_line_flow=p4_port1_remote_chamber_to_line_flow, - p4_port1_remote_node_to_primary_line_flow=( - p4_port1_remote_node_to_primary_line_flow - ), - p4_port1_remote_to_port1_line_flow=p4_port1_remote_to_port1_line_flow, - p4_port1_far_node_to_line_flow=p4_port1_far_node_to_line_flow, - p4_port1_far_chamber_to_line_flow=p4_port1_far_chamber_to_line_flow, - p4_port1_far_node_to_primary_line_flow=( - p4_port1_far_node_to_primary_line_flow - ), - p4_port1_far_to_next_line_flow=p4_port1_far_to_next_line_flow, - p4_port1_next_node_to_line_flow=p4_port1_next_node_to_line_flow, - p4_port1_next_chamber_to_line_flow=p4_port1_next_chamber_to_line_flow, - p4_port1_next_node_to_primary_line_flow=( - p4_port1_next_node_to_primary_line_flow - ), - p4_port1_next_to_bridge_line_flow=p4_port1_next_to_bridge_line_flow, - p4_port1_next_orifice_p4_to_output_line_flow=( - p4_port1_next_orifice_p4_to_output_line_flow - ), - p4_port1_next_orifice_node_to_line_flow=( - p4_port1_next_orifice_node_to_line_flow - ), - p4_port1_next_orifice_line_center_flow=( - p4_port1_next_orifice_line_center_flow - ), - p4_port1_next_orifice_to_output_line_flow=( - p4_port1_next_orifice_to_output_line_flow - ), - p4_port1_next_orifice_resistance_node_to_next_flow=( - p4_port1_next_orifice_resistance_node_to_next_flow - ), - p4_port1_next_orifice_resistance_node_to_line_flow=( - p4_port1_next_orifice_resistance_node_to_line_flow - ), - p4_port1_next_orifice_resistance_line_center_flow=( - p4_port1_next_orifice_resistance_line_center_flow - ), - p4_port1_next_orifice_resistance_to_p4_flow=( - p4_port1_next_orifice_resistance_to_p4_flow - ), - p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow=( - p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow - ), - p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow=( - p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow - ), - p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow=( - p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow - ), - p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow=( - p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow - ), - p4_bridge_to_port1_next_line_flow=p4_bridge_to_port1_next_line_flow, - p4_bridge_chamber_to_line_flow=p4_bridge_chamber_to_line_flow, - p4_bridge_node_to_primary_line_flow=p4_bridge_node_to_primary_line_flow, - p4_port1_remote_node_to_orifice_line_flow=( - p4_port1_remote_node_to_orifice_line_flow - ), - p4_port1_remote_orifice_line_center_flow=( - p4_port1_remote_orifice_line_center_flow - ), - p4_port1_remote_orifice_to_node_flow=p4_port1_remote_orifice_to_node_flow, - p4_to_port3_line_flow=p4_to_port3_line_flow, - p4_port3_remote_node_to_line_flow=p4_port3_remote_node_to_line_flow, - p4_port3_remote_chamber_to_line_flow=p4_port3_remote_chamber_to_line_flow, - p4_port3_remote_node_to_primary_line_flow=( - p4_port3_remote_node_to_primary_line_flow - ), - p4_port3_remote_to_port3_line_flow=p4_port3_remote_to_port3_line_flow, - p4_port3_far_node_to_line_flow=p4_port3_far_node_to_line_flow, - p4_port3_far_chamber_to_line_flow=p4_port3_far_chamber_to_line_flow, - p4_port3_far_node_to_primary_line_flow=( - p4_port3_far_node_to_primary_line_flow - ), - p4_port3_far_to_next_line_flow=p4_port3_far_to_next_line_flow, - p4_port3_next_node_to_line_flow=p4_port3_next_node_to_line_flow, - p4_port3_next_chamber_to_line_flow=p4_port3_next_chamber_to_line_flow, - p4_port3_next_node_to_primary_line_flow=( - p4_port3_next_node_to_primary_line_flow - ), - p4_bridge_to_port3_next_line_flow=p4_bridge_to_port3_next_line_flow, - p4_port3_next_to_bridge_line_flow=p4_port3_next_to_bridge_line_flow, - p4_port3_next_orifice_node_to_line_flow=( - p4_port3_next_orifice_node_to_line_flow - ), - p4_port3_next_orifice_line_center_flow=( - p4_port3_next_orifice_line_center_flow - ), - p4_port3_next_orifice_to_output_line_flow=( - p4_port3_next_orifice_to_output_line_flow - ), - p4_port3_next_orifice_output_to_p4_flow=( - p4_port3_next_orifice_output_to_p4_flow - ), - p4_port3_next_chamber_inlet_node_to_line_flow=( - p4_port3_next_chamber_inlet_node_to_line_flow - ), - p4_port3_next_chamber_inlet_line_to_chamber_flow=( - p4_port3_next_chamber_inlet_line_to_chamber_flow - ), - p4_port3_next_chamber_outlet_node_to_line_flow=( - p4_port3_next_chamber_outlet_node_to_line_flow - ), - p4_port3_next_chamber_outlet_line_to_chamber_flow=( - p4_port3_next_chamber_outlet_line_to_chamber_flow - ), - p4_port3_next_resistance_node_to_next_flow=( - p4_port3_next_resistance_node_to_next_flow - ), - p4_port3_next_resistance_node_to_line_flow=( - p4_port3_next_resistance_node_to_line_flow - ), - p4_port3_next_resistance_line_center_flow=( - p4_port3_next_resistance_line_center_flow - ), - p4_port3_next_resistance_to_output_line_flow=( - p4_port3_next_resistance_to_output_line_flow - ), - p4_port3_next_resistance_output_to_p4_flow=( - p4_port3_next_resistance_output_to_p4_flow - ), - p4_port3_next_resistance_chamber_inlet_node_to_line_flow=( - p4_port3_next_resistance_chamber_inlet_node_to_line_flow - ), - p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow=( - p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow - ), - p4_port3_next_resistance_chamber_outlet_node_to_line_flow=( - p4_port3_next_resistance_chamber_outlet_node_to_line_flow - ), - p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow=( - p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow - ), - p4_port3_remote_node_to_orifice_line_flow=( - p4_port3_remote_node_to_orifice_line_flow - ), - p4_port3_remote_node_to_resistance_flow=( - p4_port3_remote_node_to_resistance_flow - ), - p4_port3_remote_resistance_node_to_line_flow=( - p4_port3_remote_resistance_node_to_line_flow - ), - p4_port3_remote_resistance_line_center_flow=( - p4_port3_remote_resistance_line_center_flow - ), - p4_port3_remote_resistance_to_output_line_flow=( - p4_port3_remote_resistance_to_output_line_flow - ), - p4_port3_remote_resistance_output_to_p4_flow=( - p4_port3_remote_resistance_output_to_p4_flow - ), - p4_port3_remote_orifice_line_center_flow=( - p4_port3_remote_orifice_line_center_flow - ), - p4_port3_remote_orifice_to_node_flow=p4_port3_remote_orifice_to_node_flow, - ) - self._write_port_states(snapshot) - return snapshot - - @staticmethod - def _line_to_chamber_flow( - *, - line: ThermodynamicProperties, - chamber: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - return TestMqlPn3NodeChamberSegmentClosure._line_to_chamber_flow( - line=line, - chamber=chamber, - orifice=orifice, - ) - - @staticmethod - def _line_to_p4_flow( - *, - line: ThermodynamicProperties, - p4_boundary: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - return TestMqlPn3NodeChamberSegmentClosure._line_to_p4_flow( - line=line, - p4_boundary=p4_boundary, - orifice=orifice, - ) - - @staticmethod - def _boundary_to_p4_flow( - *, - boundary: ThermodynamicProperties, - p4_boundary: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - upstream_temperature = boundary.T if boundary.p >= p4_boundary.p else p4_boundary.T - return orifice.mass_flow(boundary.p, p4_boundary.p, upstream_temperature) - - @staticmethod - def _port( - component: AmesimPneumaticVolume | AmesimPneumaticOrifice, - port_name: str, - ) -> PortState: - return TestMqlPn3NodeChamberSegmentClosure._port(component, port_name) - - @staticmethod - def _enthalpy_flow_from_node( - *, - flow_kg_s: float, - node_h: float, - connected_h: float, - ) -> float: - return TestMqlPn3NodeChamberSegmentClosure._enthalpy_flow_from_node( - flow_kg_s=flow_kg_s, - node_h=node_h, - connected_h=connected_h, - ) - - @staticmethod - def _enthalpy_flow_for_node_port( - *, - flow_kg_s: float, - node_h: float, - connected_h: float, - port_mass_flow_kg_s: float, - ) -> float: - upstream_h = node_h if flow_kg_s >= 0.0 else connected_h - return port_mass_flow_kg_s * upstream_h - - def _two_state_orifice_line_flows( - self, - *, - line: AmesimPnl0003Pipe, - line_port_2_properties: ThermodynamicProperties, - boundary_properties: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> TestMqlTwoStateOrificeLineFlows: - return TestMqlTwoStateOrificeLineFlows( - line_center_flow=line.resistance_mass_flow(), - line_to_boundary_flow=self._line_to_p4_flow( - line=line_port_2_properties, - p4_boundary=boundary_properties, - orifice=orifice, - ), - ) - - @staticmethod - def _resistance_flow( - *, - resistance: AmesimPnl00rPipe, - port_1_properties: ThermodynamicProperties, - port_2_properties: ThermodynamicProperties, - ) -> float: - return resistance.mass_flow( - port_1_pressure_pa=port_1_properties.p, - port_1_temperature_k=port_1_properties.T, - port_2_pressure_pa=port_2_properties.p, - port_2_temperature_k=port_2_properties.T, - ) - - @staticmethod - def _p4_neighborhood_line_flows( - *, - primary_line: AmesimPnl0001Pipe, - primary_chamber_properties: ThermodynamicProperties, - port_1_line: AmesimPnl0002Pipe, - port_1_properties: ThermodynamicProperties, - port_3_line: AmesimPnl0002Pipe, - port_3_properties: ThermodynamicProperties, - ) -> TestMqlP4NeighborhoodLineFlows: - return TestMqlP4NeighborhoodLineFlows( - primary_chamber_to_line_flow=primary_line.resistance_mass_flow( - port_1_pressure_pa=primary_chamber_properties.p, - port_1_temperature_k=primary_chamber_properties.T, - ), - port_1_line_flow=port_1_line.port_mass_flow( - port_pressure_pa=port_1_properties.p, - port_temperature_k=port_1_properties.T, - ), - port_3_line_flow=port_3_line.port_mass_flow( - port_pressure_pa=port_3_properties.p, - port_temperature_k=port_3_properties.T, - ), - ) - - def _fixed_chamber_segment_flows( - self, - *, - chamber_properties: ThermodynamicProperties, - inlet_line: AmesimPnl0001Pipe, - inlet_line_properties: ThermodynamicProperties, - inlet_node_properties: ThermodynamicProperties, - inlet_orifice: AmesimPneumaticOrifice, - outlet_line: AmesimPnl0001Pipe, - outlet_line_properties: ThermodynamicProperties, - outlet_node_properties: ThermodynamicProperties, - outlet_orifice: AmesimPneumaticOrifice, - ) -> TestMqlFixedChamberSegmentFlows: - return TestMqlFixedChamberSegmentFlows( - inlet_node_to_line_flow=inlet_line.resistance_mass_flow( - port_1_pressure_pa=inlet_node_properties.p, - port_1_temperature_k=inlet_node_properties.T, - ), - inlet_line_to_chamber_flow=self._line_to_chamber_flow( - line=inlet_line_properties, - chamber=chamber_properties, - orifice=inlet_orifice, - ), - outlet_node_to_line_flow=outlet_line.resistance_mass_flow( - port_1_pressure_pa=outlet_node_properties.p, - port_1_temperature_k=outlet_node_properties.T, - ), - outlet_line_to_chamber_flow=self._line_to_chamber_flow( - line=outlet_line_properties, - chamber=chamber_properties, - orifice=outlet_orifice, - ), - ) - - def _pn3_node_balance( - self, - *, - node: TestMqlPneumaticNode3, - primary_properties: ThermodynamicProperties, - port_1: TestMqlPn3NodeBalancePort, - port_3: TestMqlPn3NodeBalancePort, - ) -> TestMqlPneumaticNode3Balance: - return node.balance( - port_2_temperature_k=primary_properties.T, - port_2_pressure_pa=primary_properties.p, - port_1_enthalpy_flow_w=self._enthalpy_flow_for_node_port( - flow_kg_s=port_1.flow_kg_s, - node_h=port_1.node_h, - connected_h=port_1.connected_h, - port_mass_flow_kg_s=port_1.port_mass_flow_kg_s, - ), - port_1_mass_flow_g_s=port_1.port_mass_flow_kg_s * 1.0e3, - port_3_enthalpy_flow_w=self._enthalpy_flow_for_node_port( - flow_kg_s=port_3.flow_kg_s, - node_h=port_3.node_h, - connected_h=port_3.connected_h, - port_mass_flow_kg_s=port_3.port_mass_flow_kg_s, - ), - port_3_mass_flow_g_s=port_3.port_mass_flow_kg_s * 1.0e3, - ) - - @staticmethod - def _pn3_node_port_2_connected_h( - *, - balance: TestMqlPneumaticNode3Balance, - fallback_h: float, - ) -> float: - mass_flow_kg_s = balance.port_2_mass_flow_g_s * 1.0e-3 - if abs(mass_flow_kg_s) <= 1.0e-12: - return fallback_h - return balance.port_2_enthalpy_flow_w / mass_flow_kg_s - - @staticmethod - def _p4_node_port_2_connected_h( - *, - balance: TestMqlPneumaticNode4Balance, - fallback_h: float, - ) -> float: - mass_flow_kg_s = balance.port_2_mass_flow_g_s * 1.0e-3 - if abs(mass_flow_kg_s) <= 1.0e-12: - return fallback_h - return balance.port_2_enthalpy_flow_w / mass_flow_kg_s - - @staticmethod - def _reference_enthalpy_flow_for_node_port( - *, - gas, - node_properties: ThermodynamicProperties, - connected_properties: ThermodynamicProperties, - flow_kg_s: float, - port_mass_flow_kg_s: float, - reference_temperature_k: float = 298.15, - ) -> float: - stream_properties = ( - node_properties if flow_kg_s >= 0.0 else connected_properties - ) - return port_mass_flow_kg_s * gas.pressure_reference_enthalpy( - stream_properties.p, - stream_properties.T, - reference_temperature=reference_temperature_k, - ) - - def _p4_port3_remote_port_2_reference_h( - self, - *, - snapshot: TestMqlPn3P4NodeChamberSegmentSnapshot, - reference_temperature_k: float = 298.15, - ) -> float: - gas = self.components.p4_port3_remote_primary_line.gas - port_2_mass_flow = snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 - if abs(port_2_mass_flow) <= 1.0e-12: - return gas.pressure_reference_enthalpy( - snapshot.p4_port3_remote_primary_line.p, - snapshot.p4_port3_remote_primary_line.T, - reference_temperature=reference_temperature_k, - ) - port_2_enthalpy_flow = sum( - ( - self._reference_enthalpy_flow_for_node_port( - gas=gas, - node_properties=snapshot.p4_port3_remote_primary_line, - connected_properties=snapshot.p4_port3_line, - flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, - reference_temperature_k=reference_temperature_k, - ), - self._reference_enthalpy_flow_for_node_port( - gas=gas, - node_properties=snapshot.p4_port3_remote_primary_line, - connected_properties=snapshot.p4_port3_remote_port3_line, - flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, - reference_temperature_k=reference_temperature_k, - ), - self._reference_enthalpy_flow_for_node_port( - gas=gas, - node_properties=snapshot.p4_port3_remote_orifice_line_port_2, - connected_properties=snapshot.p4_port3_remote_primary_line, - flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - reference_temperature_k=reference_temperature_k, - ), - ) - ) - return port_2_enthalpy_flow / port_2_mass_flow - - def _p4_node_balance( - self, - *, - node: TestMqlPneumaticNode4, - primary_properties: ThermodynamicProperties, - port_1: TestMqlP4NodeBalancePort, - port_3: TestMqlP4NodeBalancePort, - port_4: TestMqlP4NodeBalancePort, - ) -> TestMqlPneumaticNode4Balance: - return node.balance( - port_2_temperature_k=primary_properties.T, - port_2_pressure_pa=primary_properties.p, - port_1_enthalpy_flow_w=self._enthalpy_flow_for_node_port( - flow_kg_s=port_1.flow_kg_s, - node_h=port_1.node_h, - connected_h=port_1.connected_h, - port_mass_flow_kg_s=port_1.port_mass_flow_kg_s, - ), - port_1_mass_flow_g_s=port_1.port_mass_flow_kg_s * 1.0e3, - port_3_enthalpy_flow_w=self._enthalpy_flow_for_node_port( - flow_kg_s=port_3.flow_kg_s, - node_h=port_3.node_h, - connected_h=port_3.connected_h, - port_mass_flow_kg_s=port_3.port_mass_flow_kg_s, - ), - port_3_mass_flow_g_s=port_3.port_mass_flow_kg_s * 1.0e3, - port_4_enthalpy_flow_w=self._enthalpy_flow_for_node_port( - flow_kg_s=port_4.flow_kg_s, - node_h=port_4.node_h, - connected_h=port_4.connected_h, - port_mass_flow_kg_s=port_4.port_mass_flow_kg_s, - ), - port_4_mass_flow_g_s=port_4.port_mass_flow_kg_s * 1.0e3, - ) - - def _write_fixed_chamber_segment( - self, - *, - spec: TestMqlPneumaticChamberSegmentSpec, - chamber: AmesimPneumaticVolume, - chamber_properties: ThermodynamicProperties, - inlet_line: AmesimPnl0001Pipe, - inlet_line_properties: ThermodynamicProperties, - inlet_node_properties: ThermodynamicProperties, - inlet_node_to_line_flow: float, - inlet_line_to_chamber_flow: float, - inlet_orifice: AmesimPneumaticOrifice, - outlet_line: AmesimPnl0001Pipe, - outlet_line_properties: ThermodynamicProperties, - outlet_node_properties: ThermodynamicProperties, - outlet_node_to_line_flow: float, - outlet_line_to_chamber_flow: float, - outlet_orifice: AmesimPneumaticOrifice, - ) -> None: - chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) - chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) - - TestMqlPnl0001PairChamberSegmentClosure._write_line_side( - self, - line=inlet_line, - line_properties=inlet_line_properties, - node_properties=inlet_node_properties, - node_to_line_flow=inlet_node_to_line_flow, - line_to_chamber_flow=inlet_line_to_chamber_flow, - orifice=inlet_orifice, - orifice_boundary_port=spec.inlet_orifice_boundary_port, - orifice_volume_port=spec.inlet_orifice_volume_port, - chamber_port=chamber_inlet_port, - chamber_properties=chamber_properties, - ) - TestMqlPnl0001PairChamberSegmentClosure._write_line_side( - self, - line=outlet_line, - line_properties=outlet_line_properties, - node_properties=outlet_node_properties, - node_to_line_flow=outlet_node_to_line_flow, - line_to_chamber_flow=outlet_line_to_chamber_flow, - orifice=outlet_orifice, - orifice_boundary_port=spec.outlet_orifice_boundary_port, - orifice_volume_port=spec.outlet_orifice_volume_port, - chamber_port=chamber_outlet_port, - chamber_properties=chamber_properties, - ) - - def _fixed_chamber_segment_derivatives( - self, - *, - spec: TestMqlPneumaticChamberSegmentSpec, - chamber: AmesimPneumaticVolume, - chamber_properties: ThermodynamicProperties, - inlet_line: AmesimPnl0001Pipe, - inlet_line_properties: ThermodynamicProperties, - inlet_connected_properties: ThermodynamicProperties, - inlet_node_to_line_flow: float, - inlet_line_to_chamber_flow: float, - outlet_line: AmesimPnl0001Pipe, - outlet_line_properties: ThermodynamicProperties, - outlet_connected_properties: ThermodynamicProperties, - outlet_node_to_line_flow: float, - outlet_line_to_chamber_flow: float, - ) -> tuple[VolumeState, VolumeState, VolumeState]: - inlet_derivative = inlet_line.derivatives_from_connections( - port_1_m_flow=inlet_node_to_line_flow, - connected_h_1=inlet_connected_properties.h, - port_2_m_flow=-inlet_line_to_chamber_flow, - connected_h_2=chamber_properties.h, - ) - chamber_derivative = chamber.derivatives_from_two_connections( - port_a_m_flow=self._port(chamber, "port_1").m_flow, - connected_h_a=( - inlet_line_properties.h - if spec.volume_inlet_port == "port_1" - else outlet_line_properties.h - ), - port_b_m_flow=self._port(chamber, "port_2").m_flow, - connected_h_b=( - inlet_line_properties.h - if spec.volume_inlet_port == "port_2" - else outlet_line_properties.h - ), - internal_h=chamber_properties.h, - ) - outlet_derivative = outlet_line.derivatives_from_connections( - port_1_m_flow=outlet_node_to_line_flow, - connected_h_1=outlet_connected_properties.h, - port_2_m_flow=-outlet_line_to_chamber_flow, - connected_h_2=chamber_properties.h, - ) - return inlet_derivative, chamber_derivative, outlet_derivative - - @staticmethod - def _write_primary_chamber_line( - *, - line: AmesimPnl0001Pipe, - line_properties: ThermodynamicProperties, - chamber: AmesimPneumaticVolume, - chamber_properties: ThermodynamicProperties, - chamber_to_line_flow: float, - node_to_line_flow: float, - ) -> None: - line.port_1.p = chamber_properties.p - line.port_1.m_flow = chamber_to_line_flow - line.port_1.h_outflow = line_properties.h - line.port_2.p = line_properties.p - line.port_2.m_flow = node_to_line_flow - line.port_2.h_outflow = line_properties.h - - chamber.port_a.p = chamber_properties.p - chamber.port_a.m_flow = -chamber_to_line_flow - chamber.port_a.h_outflow = chamber_properties.h - - @staticmethod - def _pnl0001_reference_enthalpy_derivative( - *, - line: AmesimPnl0001Pipe, - line_properties: ThermodynamicProperties, - port_1_properties: ThermodynamicProperties, - port_1_flow: float, - port_2_properties: ThermodynamicProperties, - port_2_flow: float, - port_1_connected_reference_h: float | None = None, - port_2_connected_reference_h: float | None = None, - reference_temperature_k: float = 298.15, - ) -> VolumeState: - def specific_reference_h( - *, - port_flow: float, - connected: ThermodynamicProperties, - connected_reference_h: float | None, - ) -> float: - if port_flow > 0.0 and connected_reference_h is not None: - return connected_reference_h - if port_flow > 0.0: - pressure = connected.p - temperature = connected.T - else: - pressure = line_properties.p - temperature = line_properties.T - return line.gas.pressure_reference_enthalpy( - pressure, - temperature, - reference_temperature=reference_temperature_k, - ) - - def reference_enthalpy_flow( - *, - port_flow: float, - connected: ThermodynamicProperties, - connected_reference_h: float | None, - ) -> float: - return port_flow * specific_reference_h( - port_flow=port_flow, - connected=connected, - connected_reference_h=connected_reference_h, - ) - - mass_derivative = port_1_flow + port_2_flow - heat_flow = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * (line.external_temperature - line_properties.T) - ) - enthalpy_flow = reference_enthalpy_flow( - port_flow=port_1_flow, - connected=port_1_properties, - connected_reference_h=port_1_connected_reference_h, - ) + reference_enthalpy_flow( - port_flow=port_2_flow, - connected=port_2_properties, - connected_reference_h=port_2_connected_reference_h, - ) - reference_offset_flow = ( - line.gas.cp * reference_temperature_k - line.gas.cv * line_properties.T - ) * mass_derivative - temperature_derivative = ( - enthalpy_flow + reference_offset_flow + heat_flow - ) / (line.state.m * line.gas.cv) - return VolumeState( - m=mass_derivative, - U=line.gas.cv - * (line.state.m * temperature_derivative + line_properties.T * mass_derivative), - ) - - @staticmethod - def _primary_chamber_line_derivatives( - *, - line: AmesimPnl0001Pipe, - line_properties: ThermodynamicProperties, - chamber: AmesimPneumaticVolume, - chamber_properties: ThermodynamicProperties, - chamber_to_line_flow: float, - node_to_line_flow: float, - node_connected_h: float | None = None, - ) -> tuple[VolumeState, VolumeState]: - derivative_method = ( - line.derivatives_from_connections - if node_connected_h is None - else line.derivatives_from_transport_enthalpy_connections - ) - line_derivative = derivative_method( - port_1_m_flow=chamber_to_line_flow, - connected_h_1=chamber_properties.h, - port_2_m_flow=node_to_line_flow, - connected_h_2=( - line_properties.h if node_connected_h is None else node_connected_h - ), - ) - chamber_derivative = chamber.derivatives_from_two_connections( - port_a_m_flow=-chamber_to_line_flow, - connected_h_a=line_properties.h, - port_b_m_flow=0.0, - connected_h_b=chamber_properties.h, - internal_h=chamber_properties.h, - ) - return line_derivative, chamber_derivative - - @staticmethod - def _write_connection_line( - *, - line: AmesimPnl0001Pipe, - line_properties: ThermodynamicProperties, - port_1_properties: ThermodynamicProperties, - port_1_flow: float, - port_2_properties: ThermodynamicProperties, - port_2_flow: float, - ) -> None: - line.port_1.p = port_1_properties.p - line.port_1.m_flow = port_1_flow - line.port_1.h_outflow = line_properties.h - line.port_2.p = port_2_properties.p - line.port_2.m_flow = port_2_flow - line.port_2.h_outflow = line_properties.h - - @staticmethod - def _connection_line_derivative( - *, - line: AmesimPnl0001Pipe, - port_1_properties: ThermodynamicProperties, - port_1_flow: float, - port_2_properties: ThermodynamicProperties, - port_2_flow: float, - ) -> VolumeState: - return line.derivatives_from_connections( - port_1_m_flow=port_1_flow, - connected_h_1=port_1_properties.h, - port_2_m_flow=port_2_flow, - connected_h_2=port_2_properties.h, - ) - - @staticmethod - def _write_two_state_connection_line( - *, - line: AmesimPnl0003Pipe, - port_1_properties: ThermodynamicProperties, - port_1_flow: float, - port_2_properties: ThermodynamicProperties, - port_2_flow: float, - ) -> None: - line.port_1.p = port_1_properties.p - line.port_1.m_flow = port_1_flow - line.port_1.h_outflow = port_1_properties.h - line.port_2.p = port_2_properties.p - line.port_2.m_flow = port_2_flow - line.port_2.h_outflow = port_2_properties.h - - @staticmethod - def _write_orifice_ports( - *, - orifice: AmesimPneumaticOrifice, - port_a_properties: ThermodynamicProperties, - port_a_flow: float, - port_b_properties: ThermodynamicProperties, - port_b_flow: float, - ) -> None: - orifice.port_a.p = port_a_properties.p - orifice.port_a.m_flow = port_a_flow - orifice.port_a.h_outflow = port_a_properties.h - orifice.port_b.p = port_b_properties.p - orifice.port_b.m_flow = port_b_flow - orifice.port_b.h_outflow = port_b_properties.h - - @staticmethod - def _write_resistance_ports( - *, - resistance: AmesimPnl00rPipe, - port_1_properties: ThermodynamicProperties, - port_1_flow: float, - port_2_properties: ThermodynamicProperties, - port_2_flow: float, - ) -> None: - resistance.port_1.p = port_1_properties.p - resistance.port_1.m_flow = port_1_flow - resistance.port_1.h_outflow = port_1_properties.h - resistance.port_2.p = port_2_properties.p - resistance.port_2.m_flow = port_2_flow - resistance.port_2.h_outflow = port_2_properties.h - - @staticmethod - def _two_state_connection_line_derivatives( - *, - line: AmesimPnl0003Pipe, - port_1_properties: ThermodynamicProperties, - port_1_flow: float, - port_2_connected_properties: ThermodynamicProperties, - port_2_flow: float, - port_1_connected_h: float | None = None, - ) -> tuple[VolumeState, VolumeState]: - return line.derivatives_from_connections( - port_1_m_flow=port_1_flow, - connected_h_1=( - port_1_properties.h - if port_1_connected_h is None - else port_1_connected_h - ), - port_2_m_flow=port_2_flow, - connected_h_2=port_2_connected_properties.h, - ) - - def _write_port_states( - self, - snapshot: TestMqlPn3P4NodeChamberSegmentSnapshot, - ) -> None: - self._write_fixed_chamber_segment( - spec=self.components.spec, - chamber=self.components.volume, - chamber_properties=snapshot.chamber, - inlet_line=self.components.inlet_line, - inlet_line_properties=snapshot.inlet_line, - inlet_node_properties=snapshot.p4_port3_remote_orifice_line_port_1, - inlet_node_to_line_flow=snapshot.inlet_node_to_line_flow, - inlet_line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, - inlet_orifice=self.components.inlet_orifice, - outlet_line=self.components.outlet_line, - outlet_line_properties=snapshot.outlet_line, - outlet_node_properties=snapshot.node_line_port_1, - outlet_node_to_line_flow=snapshot.outlet_node_to_line_flow, - outlet_line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, - outlet_orifice=self.components.outlet_orifice, - ) - - self._write_two_state_connection_line( - line=self.components.node_line, - port_1_properties=snapshot.node_line_port_1, - port_1_flow=snapshot.node_to_pnl0003_flow, - port_2_properties=snapshot.node_line_port_2, - port_2_flow=-snapshot.pnl0003_to_p4_flow, - ) - - self._write_orifice_ports( - orifice=self.components.node_orifice, - port_a_properties=snapshot.node_line_port_2, - port_a_flow=snapshot.pnl0003_to_p4_flow, - port_b_properties=snapshot.p4_primary_line, - port_b_flow=-snapshot.pnl0003_to_p4_flow, - ) - - self._write_resistance_ports( - resistance=self.components.node_resistance, - port_1_properties=snapshot.node_line_port_1, - port_1_flow=snapshot.node_to_resistance_flow, - port_2_properties=snapshot.p4_port1_remote_orifice_line_port_1, - port_2_flow=-snapshot.node_to_resistance_flow, - ) - - self._write_primary_chamber_line( - line=self.components.p4_primary_line, - line_properties=snapshot.p4_primary_line, - chamber=self.components.p4_primary_chamber, - chamber_properties=snapshot.p4_primary_chamber, - chamber_to_line_flow=snapshot.p4_primary_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_node_to_primary_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port1_line, - line_properties=snapshot.p4_port1_line, - port_1_properties=snapshot.p4_primary_line, - port_1_flow=snapshot.p4_to_port1_line_flow, - port_2_properties=snapshot.p4_port1_remote_primary_line, - port_2_flow=snapshot.p4_port1_remote_node_to_line_flow, - ) - - self._write_primary_chamber_line( - line=self.components.p4_port1_remote_primary_line, - line_properties=snapshot.p4_port1_remote_primary_line, - chamber=self.components.p4_port1_remote_primary_chamber, - chamber_properties=snapshot.p4_port1_remote_primary_chamber, - chamber_to_line_flow=snapshot.p4_port1_remote_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port1_remote_node_to_primary_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port1_remote_port1_line, - line_properties=snapshot.p4_port1_remote_port1_line, - port_1_properties=snapshot.p4_port1_remote_primary_line, - port_1_flow=snapshot.p4_port1_remote_to_port1_line_flow, - port_2_properties=snapshot.p4_port1_far_primary_line, - port_2_flow=snapshot.p4_port1_far_node_to_line_flow, - ) - - self._write_primary_chamber_line( - line=self.components.p4_port1_far_primary_line, - line_properties=snapshot.p4_port1_far_primary_line, - chamber=self.components.p4_port1_far_primary_chamber, - chamber_properties=snapshot.p4_port1_far_primary_chamber, - chamber_to_line_flow=snapshot.p4_port1_far_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port1_far_node_to_primary_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port1_far_port1_line, - line_properties=snapshot.p4_port1_far_port1_line, - port_1_properties=snapshot.p4_port1_far_primary_line, - port_1_flow=snapshot.p4_port1_far_to_next_line_flow, - port_2_properties=snapshot.p4_port1_next_primary_line, - port_2_flow=snapshot.p4_port1_next_node_to_line_flow, - ) - - self._write_primary_chamber_line( - line=self.components.p4_port1_next_primary_line, - line_properties=snapshot.p4_port1_next_primary_line, - chamber=self.components.p4_port1_next_primary_chamber, - chamber_properties=snapshot.p4_port1_next_primary_chamber, - chamber_to_line_flow=snapshot.p4_port1_next_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port1_next_node_to_primary_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port1_next_port1_line, - line_properties=snapshot.p4_port1_next_port1_line, - port_1_properties=snapshot.p4_port1_next_primary_line, - port_1_flow=snapshot.p4_port1_next_to_bridge_line_flow, - port_2_properties=snapshot.p4_bridge_primary_line, - port_2_flow=snapshot.p4_bridge_to_port1_next_line_flow, - ) - - self._write_two_state_connection_line( - line=self.components.p4_port1_next_orifice_line, - port_1_properties=snapshot.p4_port1_next_orifice_line_port_1, - port_1_flow=snapshot.p4_port1_next_orifice_node_to_line_flow, - port_2_properties=snapshot.p4_port1_next_orifice_line_port_2, - port_2_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, - ) - - self._write_orifice_ports( - orifice=self.components.p4_port1_next_orifice, - port_a_properties=snapshot.p4_port1_next_orifice_line_port_2, - port_a_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, - port_b_properties=snapshot.p4_port1_next_orifice_output_line, - port_b_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port1_next_orifice_output_line, - line_properties=snapshot.p4_port1_next_orifice_output_line, - port_1_properties=snapshot.p4_port1_next_primary_line, - port_1_flow=snapshot.p4_port1_next_orifice_p4_to_output_line_flow, - port_2_properties=snapshot.p4_port1_next_orifice_output_line, - port_2_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, - ) - - self._write_resistance_ports( - resistance=self.components.p4_port1_next_orifice_resistance, - port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, - port_1_flow=( - snapshot.p4_port1_next_orifice_resistance_node_to_next_flow - ), - port_2_properties=snapshot.p4_port1_next_orifice_line_port_1, - port_2_flow=( - -snapshot.p4_port1_next_orifice_resistance_node_to_next_flow - ), - ) - - self._write_two_state_connection_line( - line=self.components.p4_port1_next_orifice_resistance_line, - port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, - port_1_flow=snapshot.p4_port1_next_orifice_resistance_node_to_line_flow, - port_2_properties=snapshot.p4_port1_next_orifice_resistance_line_port_2, - port_2_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, - ) - - self._write_orifice_ports( - orifice=self.components.p4_port1_next_orifice_resistance_orifice, - port_a_properties=snapshot.p4_port1_next_orifice_resistance_line_port_2, - port_a_flow=snapshot.p4_port1_next_orifice_resistance_to_p4_flow, - port_b_properties=snapshot.p4_port1_far_primary_line, - port_b_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, - ) - - self._write_fixed_chamber_segment( - spec=self.components.p4_port1_next_orifice_resistance_chamber_spec, - chamber=self.components.p4_port1_next_orifice_resistance_chamber, - chamber_properties=snapshot.p4_port1_next_orifice_resistance_chamber, - inlet_line=self.components.p4_port1_next_orifice_resistance_chamber_inlet_line, - inlet_line_properties=( - snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line - ), - inlet_node_properties=snapshot.p4_port1_remote_orifice_line_port_1, - inlet_node_to_line_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow - ), - inlet_line_to_chamber_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow - ), - inlet_orifice=( - self.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice - ), - outlet_line=( - self.components.p4_port1_next_orifice_resistance_chamber_outlet_line - ), - outlet_line_properties=( - snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line - ), - outlet_node_properties=( - snapshot.p4_port1_next_orifice_resistance_line_port_1 - ), - outlet_node_to_line_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow - ), - outlet_line_to_chamber_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow - ), - outlet_orifice=( - self.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice - ), - ) - - self._write_primary_chamber_line( - line=self.components.p4_bridge_primary_line, - line_properties=snapshot.p4_bridge_primary_line, - chamber=self.components.p4_bridge_primary_chamber, - chamber_properties=snapshot.p4_bridge_primary_chamber, - chamber_to_line_flow=snapshot.p4_bridge_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_bridge_node_to_primary_line_flow, - ) - - self._write_two_state_connection_line( - line=self.components.p4_port1_remote_orifice_line, - port_1_properties=snapshot.p4_port1_remote_orifice_line_port_1, - port_1_flow=snapshot.p4_port1_remote_node_to_orifice_line_flow, - port_2_properties=snapshot.p4_port1_remote_orifice_line_port_2, - port_2_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, - ) - - self._write_orifice_ports( - orifice=self.components.p4_port1_remote_orifice, - port_a_properties=snapshot.p4_port1_remote_orifice_line_port_2, - port_a_flow=snapshot.p4_port1_remote_orifice_to_node_flow, - port_b_properties=snapshot.p4_port1_remote_primary_line, - port_b_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, - ) - - self._write_connection_line( - line=self.components.p4_port3_line, - line_properties=snapshot.p4_port3_line, - port_1_properties=snapshot.p4_port3_remote_primary_line, - port_1_flow=snapshot.p4_port3_remote_node_to_line_flow, - port_2_properties=snapshot.p4_primary_line, - port_2_flow=snapshot.p4_to_port3_line_flow, - ) - - self._write_primary_chamber_line( - line=self.components.p4_port3_remote_primary_line, - line_properties=snapshot.p4_port3_remote_primary_line, - chamber=self.components.p4_port3_remote_primary_chamber, - chamber_properties=snapshot.p4_port3_remote_primary_chamber, - chamber_to_line_flow=snapshot.p4_port3_remote_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port3_remote_port3_line, - line_properties=snapshot.p4_port3_remote_port3_line, - port_1_properties=snapshot.p4_port3_far_primary_line, - port_1_flow=snapshot.p4_port3_far_node_to_line_flow, - port_2_properties=snapshot.p4_port3_remote_primary_line, - port_2_flow=snapshot.p4_port3_remote_to_port3_line_flow, - ) - - self._write_primary_chamber_line( - line=self.components.p4_port3_far_primary_line, - line_properties=snapshot.p4_port3_far_primary_line, - chamber=self.components.p4_port3_far_primary_chamber, - chamber_properties=snapshot.p4_port3_far_primary_chamber, - chamber_to_line_flow=snapshot.p4_port3_far_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port3_far_node_to_primary_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port3_far_port3_line, - line_properties=snapshot.p4_port3_far_port3_line, - port_1_properties=snapshot.p4_port3_next_primary_line, - port_1_flow=snapshot.p4_port3_next_node_to_line_flow, - port_2_properties=snapshot.p4_port3_far_primary_line, - port_2_flow=snapshot.p4_port3_far_to_next_line_flow, - ) - - self._write_primary_chamber_line( - line=self.components.p4_port3_next_primary_line, - line_properties=snapshot.p4_port3_next_primary_line, - chamber=self.components.p4_port3_next_primary_chamber, - chamber_properties=snapshot.p4_port3_next_primary_chamber, - chamber_to_line_flow=snapshot.p4_port3_next_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port3_next_node_to_primary_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port3_next_port3_line, - line_properties=snapshot.p4_port3_next_port3_line, - port_1_properties=snapshot.p4_bridge_primary_line, - port_1_flow=snapshot.p4_bridge_to_port3_next_line_flow, - port_2_properties=snapshot.p4_port3_next_primary_line, - port_2_flow=snapshot.p4_port3_next_to_bridge_line_flow, - ) - - self._write_two_state_connection_line( - line=self.components.p4_port3_next_orifice_line, - port_1_properties=snapshot.p4_port3_next_orifice_line_port_1, - port_1_flow=snapshot.p4_port3_next_orifice_node_to_line_flow, - port_2_properties=snapshot.p4_port3_next_orifice_line_port_2, - port_2_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, - ) - - self._write_orifice_ports( - orifice=self.components.p4_port3_next_orifice, - port_a_properties=snapshot.p4_port3_next_orifice_line_port_2, - port_a_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, - port_b_properties=snapshot.p4_port3_next_orifice_output_line, - port_b_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port3_next_orifice_output_line, - line_properties=snapshot.p4_port3_next_orifice_output_line, - port_1_properties=snapshot.p4_port3_next_orifice_line_port_2, - port_1_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, - port_2_properties=snapshot.p4_port3_next_orifice_output_line, - port_2_flow=-snapshot.p4_port3_next_orifice_output_to_p4_flow, - ) - - self._write_fixed_chamber_segment( - spec=self.components.p4_port3_next_chamber_spec, - chamber=self.components.p4_port3_next_chamber, - chamber_properties=snapshot.p4_port3_next_chamber, - inlet_line=self.components.p4_port3_next_chamber_inlet_line, - inlet_line_properties=snapshot.p4_port3_next_chamber_inlet_line, - inlet_node_properties=snapshot.p4_port3_next_orifice_line_port_1, - inlet_node_to_line_flow=( - snapshot.p4_port3_next_chamber_inlet_node_to_line_flow - ), - inlet_line_to_chamber_flow=( - snapshot.p4_port3_next_chamber_inlet_line_to_chamber_flow - ), - inlet_orifice=self.components.p4_port3_next_chamber_inlet_orifice, - outlet_line=self.components.p4_port3_next_chamber_outlet_line, - outlet_line_properties=snapshot.p4_port3_next_chamber_outlet_line, - outlet_node_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, - outlet_node_to_line_flow=( - snapshot.p4_port3_next_chamber_outlet_node_to_line_flow - ), - outlet_line_to_chamber_flow=( - snapshot.p4_port3_next_chamber_outlet_line_to_chamber_flow - ), - outlet_orifice=self.components.p4_port3_next_chamber_outlet_orifice, - ) - - self._write_resistance_ports( - resistance=self.components.p4_port3_next_orifice_resistance, - port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, - port_1_flow=snapshot.p4_port3_next_resistance_node_to_next_flow, - port_2_properties=snapshot.p4_port3_next_orifice_line_port_1, - port_2_flow=-snapshot.p4_port3_next_resistance_node_to_next_flow, - ) - - self._write_two_state_connection_line( - line=self.components.p4_port3_next_resistance_node_line, - port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, - port_1_flow=snapshot.p4_port3_next_resistance_node_to_line_flow, - port_2_properties=snapshot.p4_port3_next_resistance_node_line_port_2, - port_2_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, - ) - - self._write_orifice_ports( - orifice=self.components.p4_port3_next_resistance_orifice, - port_a_properties=snapshot.p4_port3_next_resistance_node_line_port_2, - port_a_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, - port_b_properties=snapshot.p4_port3_next_resistance_output_line, - port_b_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port3_next_resistance_output_line, - line_properties=snapshot.p4_port3_next_resistance_output_line, - port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_2, - port_1_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, - port_2_properties=snapshot.p4_port3_next_resistance_output_line, - port_2_flow=-snapshot.p4_port3_next_resistance_output_to_p4_flow, - ) - - self._write_fixed_chamber_segment( - spec=self.components.p4_port3_next_resistance_chamber_spec, - chamber=self.components.p4_port3_next_resistance_chamber, - chamber_properties=snapshot.p4_port3_next_resistance_chamber, - inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, - inlet_line_properties=( - snapshot.p4_port3_next_resistance_chamber_inlet_line - ), - inlet_node_properties=snapshot.p4_port1_next_orifice_line_port_1, - inlet_node_to_line_flow=( - snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow - ), - inlet_line_to_chamber_flow=( - snapshot.p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow - ), - inlet_orifice=( - self.components.p4_port3_next_resistance_chamber_inlet_orifice - ), - outlet_line=self.components.p4_port3_next_resistance_chamber_outlet_line, - outlet_line_properties=( - snapshot.p4_port3_next_resistance_chamber_outlet_line - ), - outlet_node_properties=snapshot.p4_port3_next_resistance_node_line_port_1, - outlet_node_to_line_flow=( - snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow - ), - outlet_line_to_chamber_flow=( - snapshot.p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow - ), - outlet_orifice=( - self.components.p4_port3_next_resistance_chamber_outlet_orifice - ), - ) - - self._write_two_state_connection_line( - line=self.components.p4_port3_remote_orifice_line, - port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, - port_1_flow=snapshot.p4_port3_remote_node_to_orifice_line_flow, - port_2_properties=snapshot.p4_port3_remote_orifice_line_port_2, - port_2_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, - ) - - self._write_resistance_ports( - resistance=self.components.p4_port3_remote_orifice_resistance, - port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, - port_1_flow=snapshot.p4_port3_remote_node_to_resistance_flow, - port_2_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, - port_2_flow=-snapshot.p4_port3_remote_node_to_resistance_flow, - ) - - self._write_two_state_connection_line( - line=self.components.p4_port3_remote_resistance_node_line, - port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, - port_1_flow=snapshot.p4_port3_remote_resistance_node_to_line_flow, - port_2_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, - port_2_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, - ) - - self._write_orifice_ports( - orifice=self.components.p4_port3_remote_resistance_orifice, - port_a_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, - port_a_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, - port_b_properties=snapshot.p4_port3_remote_resistance_output_line, - port_b_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, - ) - - self._write_connection_line( - line=self.components.p4_port3_remote_resistance_output_line, - line_properties=snapshot.p4_port3_remote_resistance_output_line, - port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, - port_1_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, - port_2_properties=snapshot.p4_port3_remote_resistance_output_line, - port_2_flow=-snapshot.p4_port3_remote_resistance_output_to_p4_flow, - ) - - self._write_orifice_ports( - orifice=self.components.p4_port3_remote_orifice, - port_a_properties=snapshot.p4_port3_remote_orifice_line_port_2, - port_a_flow=snapshot.p4_port3_remote_orifice_to_node_flow, - port_b_properties=snapshot.p4_port3_remote_primary_line, - port_b_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, - ) - - def rhs(self, state_vector: list[float]) -> list[float]: - snapshot = self.snapshot(state_vector) - ( - inlet_derivative, - chamber_derivative, - outlet_derivative, - ) = self._fixed_chamber_segment_derivatives( - spec=self.components.spec, - chamber=self.components.volume, - chamber_properties=snapshot.chamber, - inlet_line=self.components.inlet_line, - inlet_line_properties=snapshot.inlet_line, - inlet_connected_properties=snapshot.p4_port3_remote_orifice_line_port_1, - inlet_node_to_line_flow=snapshot.inlet_node_to_line_flow, - inlet_line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, - outlet_line=self.components.outlet_line, - outlet_line_properties=snapshot.outlet_line, - outlet_connected_properties=snapshot.node_line_port_1, - outlet_node_to_line_flow=snapshot.outlet_node_to_line_flow, - outlet_line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, - ) - if self.use_inlet_line_reference_rhs: - inlet_derivative = self._pnl0001_reference_enthalpy_derivative( - line=self.components.inlet_line, - line_properties=snapshot.inlet_line, - port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, - port_1_flow=snapshot.inlet_node_to_line_flow, - port_2_properties=snapshot.chamber, - port_2_flow=-snapshot.inlet_line_to_chamber_flow, - ) - ( - node_line_derivative_1, - node_line_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.node_line, - port_1_properties=snapshot.node_line_port_1, - port_1_flow=snapshot.node_to_pnl0003_flow, - port_2_connected_properties=snapshot.p4_primary_line, - port_2_flow=-snapshot.pnl0003_to_p4_flow, - ) - ( - p4_primary_derivative, - p4_primary_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_primary_line, - line_properties=snapshot.p4_primary_line, - chamber=self.components.p4_primary_chamber, - chamber_properties=snapshot.p4_primary_chamber, - chamber_to_line_flow=snapshot.p4_primary_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_node_to_primary_line_flow, - ) - p4_port1_derivative = self._connection_line_derivative( - line=self.components.p4_port1_line, - port_1_properties=snapshot.p4_primary_line, - port_1_flow=snapshot.p4_to_port1_line_flow, - port_2_properties=snapshot.p4_port1_remote_primary_line, - port_2_flow=snapshot.p4_port1_remote_node_to_line_flow, - ) - ( - p4_port1_remote_primary_derivative, - p4_port1_remote_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_port1_remote_primary_line, - line_properties=snapshot.p4_port1_remote_primary_line, - chamber=self.components.p4_port1_remote_primary_chamber, - chamber_properties=snapshot.p4_port1_remote_primary_chamber, - chamber_to_line_flow=snapshot.p4_port1_remote_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port1_remote_node_to_primary_line_flow, - ) - p4_port1_remote_port1_derivative = self._connection_line_derivative( - line=self.components.p4_port1_remote_port1_line, - port_1_properties=snapshot.p4_port1_remote_primary_line, - port_1_flow=snapshot.p4_port1_remote_to_port1_line_flow, - port_2_properties=snapshot.p4_port1_far_primary_line, - port_2_flow=snapshot.p4_port1_far_node_to_line_flow, - ) - ( - p4_port1_far_primary_derivative, - p4_port1_far_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_port1_far_primary_line, - line_properties=snapshot.p4_port1_far_primary_line, - chamber=self.components.p4_port1_far_primary_chamber, - chamber_properties=snapshot.p4_port1_far_primary_chamber, - chamber_to_line_flow=snapshot.p4_port1_far_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port1_far_node_to_primary_line_flow, - ) - p4_port1_far_port1_derivative = self._connection_line_derivative( - line=self.components.p4_port1_far_port1_line, - port_1_properties=snapshot.p4_port1_far_primary_line, - port_1_flow=snapshot.p4_port1_far_to_next_line_flow, - port_2_properties=snapshot.p4_port1_next_primary_line, - port_2_flow=snapshot.p4_port1_next_node_to_line_flow, - ) - ( - p4_port1_next_primary_derivative, - p4_port1_next_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_port1_next_primary_line, - line_properties=snapshot.p4_port1_next_primary_line, - chamber=self.components.p4_port1_next_primary_chamber, - chamber_properties=snapshot.p4_port1_next_primary_chamber, - chamber_to_line_flow=snapshot.p4_port1_next_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port1_next_node_to_primary_line_flow, - ) - p4_port1_next_port1_derivative = self._connection_line_derivative( - line=self.components.p4_port1_next_port1_line, - port_1_properties=snapshot.p4_port1_next_primary_line, - port_1_flow=snapshot.p4_port1_next_to_bridge_line_flow, - port_2_properties=snapshot.p4_bridge_primary_line, - port_2_flow=snapshot.p4_bridge_to_port1_next_line_flow, - ) - ( - p4_bridge_primary_derivative, - p4_bridge_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_bridge_primary_line, - line_properties=snapshot.p4_bridge_primary_line, - chamber=self.components.p4_bridge_primary_chamber, - chamber_properties=snapshot.p4_bridge_primary_chamber, - chamber_to_line_flow=snapshot.p4_bridge_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_bridge_node_to_primary_line_flow, - ) - ( - p4_port1_remote_orifice_derivative_1, - p4_port1_remote_orifice_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.p4_port1_remote_orifice_line, - port_1_properties=snapshot.p4_port1_remote_orifice_line_port_1, - port_1_flow=snapshot.p4_port1_remote_node_to_orifice_line_flow, - port_2_connected_properties=snapshot.p4_port1_remote_primary_line, - port_2_flow=-snapshot.p4_port1_remote_orifice_to_node_flow, - ) - p4_port3_derivative = self._connection_line_derivative( - line=self.components.p4_port3_line, - port_1_properties=snapshot.p4_port3_remote_primary_line, - port_1_flow=snapshot.p4_port3_remote_node_to_line_flow, - port_2_properties=snapshot.p4_primary_line, - port_2_flow=snapshot.p4_to_port3_line_flow, - ) - p4_port3_remote_node_connected_h = self._p4_node_port_2_connected_h( - balance=snapshot.p4_port3_remote_balance, - fallback_h=snapshot.p4_port3_remote_primary_line.h, - ) - ( - p4_port3_remote_primary_derivative, - p4_port3_remote_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_port3_remote_primary_line, - line_properties=snapshot.p4_port3_remote_primary_line, - chamber=self.components.p4_port3_remote_primary_chamber, - chamber_properties=snapshot.p4_port3_remote_primary_chamber, - chamber_to_line_flow=snapshot.p4_port3_remote_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, - node_connected_h=p4_port3_remote_node_connected_h, - ) - if self.use_p4_port3_remote_primary_reference_rhs: - p4_port3_remote_primary_derivative = ( - self._pnl0001_reference_enthalpy_derivative( - line=self.components.p4_port3_remote_primary_line, - line_properties=snapshot.p4_port3_remote_primary_line, - port_1_properties=snapshot.p4_port3_remote_primary_chamber, - port_1_flow=snapshot.p4_port3_remote_chamber_to_line_flow, - port_2_properties=snapshot.p4_port3_remote_primary_line, - port_2_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, - port_2_connected_reference_h=( - self._p4_port3_remote_port_2_reference_h( - snapshot=snapshot - ) - ), - ) - ) - p4_port3_remote_port3_derivative = self._connection_line_derivative( - line=self.components.p4_port3_remote_port3_line, - port_1_properties=snapshot.p4_port3_far_primary_line, - port_1_flow=snapshot.p4_port3_far_node_to_line_flow, - port_2_properties=snapshot.p4_port3_remote_primary_line, - port_2_flow=snapshot.p4_port3_remote_to_port3_line_flow, - ) - ( - p4_port3_far_primary_derivative, - p4_port3_far_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_port3_far_primary_line, - line_properties=snapshot.p4_port3_far_primary_line, - chamber=self.components.p4_port3_far_primary_chamber, - chamber_properties=snapshot.p4_port3_far_primary_chamber, - chamber_to_line_flow=snapshot.p4_port3_far_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port3_far_node_to_primary_line_flow, - ) - p4_port3_far_port3_derivative = self._connection_line_derivative( - line=self.components.p4_port3_far_port3_line, - port_1_properties=snapshot.p4_port3_next_primary_line, - port_1_flow=snapshot.p4_port3_next_node_to_line_flow, - port_2_properties=snapshot.p4_port3_far_primary_line, - port_2_flow=snapshot.p4_port3_far_to_next_line_flow, - ) - ( - p4_port3_next_primary_derivative, - p4_port3_next_chamber_derivative, - ) = self._primary_chamber_line_derivatives( - line=self.components.p4_port3_next_primary_line, - line_properties=snapshot.p4_port3_next_primary_line, - chamber=self.components.p4_port3_next_primary_chamber, - chamber_properties=snapshot.p4_port3_next_primary_chamber, - chamber_to_line_flow=snapshot.p4_port3_next_chamber_to_line_flow, - node_to_line_flow=snapshot.p4_port3_next_node_to_primary_line_flow, - ) - p4_port3_next_port3_derivative = self._connection_line_derivative( - line=self.components.p4_port3_next_port3_line, - port_1_properties=snapshot.p4_bridge_primary_line, - port_1_flow=snapshot.p4_bridge_to_port3_next_line_flow, - port_2_properties=snapshot.p4_port3_next_primary_line, - port_2_flow=snapshot.p4_port3_next_to_bridge_line_flow, - ) - ( - p4_port3_remote_resistance_derivative_1, - p4_port3_remote_resistance_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.p4_port3_remote_resistance_node_line, - port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_1, - port_1_flow=snapshot.p4_port3_remote_resistance_node_to_line_flow, - port_2_connected_properties=snapshot.p4_port3_remote_resistance_output_line, - port_2_flow=-snapshot.p4_port3_remote_resistance_to_output_line_flow, - ) - p4_port3_remote_resistance_output_derivative = self._connection_line_derivative( - line=self.components.p4_port3_remote_resistance_output_line, - port_1_properties=snapshot.p4_port3_remote_resistance_node_line_port_2, - port_1_flow=snapshot.p4_port3_remote_resistance_to_output_line_flow, - port_2_properties=snapshot.p4_port3_far_primary_line, - port_2_flow=-snapshot.p4_port3_remote_resistance_output_to_p4_flow, - ) - ( - p4_port3_remote_orifice_derivative_1, - p4_port3_remote_orifice_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.p4_port3_remote_orifice_line, - port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, - port_1_flow=snapshot.p4_port3_remote_node_to_orifice_line_flow, - port_2_connected_properties=snapshot.p4_port3_remote_primary_line, - port_2_flow=-snapshot.p4_port3_remote_orifice_to_node_flow, - port_1_connected_h=self._pn3_node_port_2_connected_h( - balance=snapshot.p4_port3_remote_orifice_node_balance, - fallback_h=snapshot.p4_port3_remote_orifice_line_port_1.h, - ), - ) - ( - p4_port3_next_orifice_derivative_1, - p4_port3_next_orifice_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.p4_port3_next_orifice_line, - port_1_properties=snapshot.p4_port3_next_orifice_line_port_1, - port_1_flow=snapshot.p4_port3_next_orifice_node_to_line_flow, - port_2_connected_properties=snapshot.p4_port3_next_orifice_output_line, - port_2_flow=-snapshot.p4_port3_next_orifice_to_output_line_flow, - ) - p4_port3_next_orifice_output_derivative = self._connection_line_derivative( - line=self.components.p4_port3_next_orifice_output_line, - port_1_properties=snapshot.p4_port3_next_orifice_line_port_2, - port_1_flow=snapshot.p4_port3_next_orifice_to_output_line_flow, - port_2_properties=snapshot.p4_port3_next_primary_line, - port_2_flow=-snapshot.p4_port3_next_orifice_output_to_p4_flow, - ) - ( - p4_port3_next_chamber_inlet_derivative, - p4_port3_next_fixed_chamber_derivative, - p4_port3_next_chamber_outlet_derivative, - ) = self._fixed_chamber_segment_derivatives( - spec=self.components.p4_port3_next_chamber_spec, - chamber=self.components.p4_port3_next_chamber, - chamber_properties=snapshot.p4_port3_next_chamber, - inlet_line=self.components.p4_port3_next_chamber_inlet_line, - inlet_line_properties=snapshot.p4_port3_next_chamber_inlet_line, - inlet_connected_properties=snapshot.p4_port3_next_orifice_line_port_1, - inlet_node_to_line_flow=( - snapshot.p4_port3_next_chamber_inlet_node_to_line_flow - ), - inlet_line_to_chamber_flow=( - snapshot.p4_port3_next_chamber_inlet_line_to_chamber_flow - ), - outlet_line=self.components.p4_port3_next_chamber_outlet_line, - outlet_line_properties=snapshot.p4_port3_next_chamber_outlet_line, - outlet_connected_properties=( - snapshot.p4_port3_remote_resistance_node_line_port_1 - ), - outlet_node_to_line_flow=( - snapshot.p4_port3_next_chamber_outlet_node_to_line_flow - ), - outlet_line_to_chamber_flow=( - snapshot.p4_port3_next_chamber_outlet_line_to_chamber_flow - ), - ) - ( - p4_port3_next_resistance_derivative_1, - p4_port3_next_resistance_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.p4_port3_next_resistance_node_line, - port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_1, - port_1_flow=snapshot.p4_port3_next_resistance_node_to_line_flow, - port_2_connected_properties=snapshot.p4_port3_next_resistance_output_line, - port_2_flow=-snapshot.p4_port3_next_resistance_to_output_line_flow, - ) - p4_port3_next_resistance_output_derivative = self._connection_line_derivative( - line=self.components.p4_port3_next_resistance_output_line, - port_1_properties=snapshot.p4_port3_next_resistance_node_line_port_2, - port_1_flow=snapshot.p4_port3_next_resistance_to_output_line_flow, - port_2_properties=snapshot.p4_bridge_primary_line, - port_2_flow=-snapshot.p4_port3_next_resistance_output_to_p4_flow, - ) - ( - p4_port3_next_resistance_chamber_inlet_derivative, - p4_port3_next_resistance_fixed_chamber_derivative, - p4_port3_next_resistance_chamber_outlet_derivative, - ) = self._fixed_chamber_segment_derivatives( - spec=self.components.p4_port3_next_resistance_chamber_spec, - chamber=self.components.p4_port3_next_resistance_chamber, - chamber_properties=snapshot.p4_port3_next_resistance_chamber, - inlet_line=self.components.p4_port3_next_resistance_chamber_inlet_line, - inlet_line_properties=( - snapshot.p4_port3_next_resistance_chamber_inlet_line - ), - inlet_connected_properties=snapshot.p4_port1_next_orifice_line_port_1, - inlet_node_to_line_flow=( - snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow - ), - inlet_line_to_chamber_flow=( - snapshot.p4_port3_next_resistance_chamber_inlet_line_to_chamber_flow - ), - outlet_line=self.components.p4_port3_next_resistance_chamber_outlet_line, - outlet_line_properties=( - snapshot.p4_port3_next_resistance_chamber_outlet_line - ), - outlet_connected_properties=snapshot.p4_port3_next_resistance_node_line_port_1, - outlet_node_to_line_flow=( - snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow - ), - outlet_line_to_chamber_flow=( - snapshot.p4_port3_next_resistance_chamber_outlet_line_to_chamber_flow - ), - ) - ( - p4_port1_next_orifice_derivative_1, - p4_port1_next_orifice_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.p4_port1_next_orifice_line, - port_1_properties=snapshot.p4_port1_next_orifice_line_port_1, - port_1_flow=snapshot.p4_port1_next_orifice_node_to_line_flow, - port_2_connected_properties=snapshot.p4_port1_next_orifice_output_line, - port_2_flow=-snapshot.p4_port1_next_orifice_to_output_line_flow, - ) - p4_port1_next_orifice_output_derivative = self._connection_line_derivative( - line=self.components.p4_port1_next_orifice_output_line, - port_1_properties=snapshot.p4_port1_next_primary_line, - port_1_flow=snapshot.p4_port1_next_orifice_p4_to_output_line_flow, - port_2_properties=snapshot.p4_port1_next_orifice_line_port_2, - port_2_flow=snapshot.p4_port1_next_orifice_to_output_line_flow, - ) - ( - p4_port1_next_orifice_resistance_derivative_1, - p4_port1_next_orifice_resistance_derivative_2, - ) = self._two_state_connection_line_derivatives( - line=self.components.p4_port1_next_orifice_resistance_line, - port_1_properties=snapshot.p4_port1_next_orifice_resistance_line_port_1, - port_1_flow=snapshot.p4_port1_next_orifice_resistance_node_to_line_flow, - port_2_connected_properties=snapshot.p4_port1_far_primary_line, - port_2_flow=-snapshot.p4_port1_next_orifice_resistance_to_p4_flow, - ) - ( - p4_port1_next_orifice_resistance_chamber_inlet_derivative, - p4_port1_next_orifice_resistance_fixed_chamber_derivative, - p4_port1_next_orifice_resistance_chamber_outlet_derivative, - ) = self._fixed_chamber_segment_derivatives( - spec=self.components.p4_port1_next_orifice_resistance_chamber_spec, - chamber=self.components.p4_port1_next_orifice_resistance_chamber, - chamber_properties=snapshot.p4_port1_next_orifice_resistance_chamber, - inlet_line=( - self.components.p4_port1_next_orifice_resistance_chamber_inlet_line - ), - inlet_line_properties=( - snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line - ), - inlet_connected_properties=snapshot.p4_port1_remote_orifice_line_port_1, - inlet_node_to_line_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow - ), - inlet_line_to_chamber_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_inlet_line_to_chamber_flow - ), - outlet_line=( - self.components.p4_port1_next_orifice_resistance_chamber_outlet_line - ), - outlet_line_properties=( - snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line - ), - outlet_connected_properties=( - snapshot.p4_port1_next_orifice_resistance_line_port_1 - ), - outlet_node_to_line_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow - ), - outlet_line_to_chamber_flow=( - snapshot.p4_port1_next_orifice_resistance_chamber_outlet_line_to_chamber_flow - ), - ) - return [ - *inlet_derivative.as_vector(), - *chamber_derivative.as_vector(), - *outlet_derivative.as_vector(), - *node_line_derivative_1.as_vector(), - *node_line_derivative_2.as_vector(), - *p4_primary_derivative.as_vector(), - *p4_primary_chamber_derivative.as_vector(), - *p4_port1_derivative.as_vector(), - *p4_port1_remote_primary_derivative.as_vector(), - *p4_port1_remote_chamber_derivative.as_vector(), - *p4_port1_remote_port1_derivative.as_vector(), - *p4_port1_far_primary_derivative.as_vector(), - *p4_port1_far_chamber_derivative.as_vector(), - *p4_port1_far_port1_derivative.as_vector(), - *p4_port1_next_primary_derivative.as_vector(), - *p4_port1_next_chamber_derivative.as_vector(), - *p4_port1_next_port1_derivative.as_vector(), - *p4_bridge_primary_derivative.as_vector(), - *p4_bridge_chamber_derivative.as_vector(), - *p4_port1_remote_orifice_derivative_1.as_vector(), - *p4_port1_remote_orifice_derivative_2.as_vector(), - *p4_port3_derivative.as_vector(), - *p4_port3_remote_primary_derivative.as_vector(), - *p4_port3_remote_chamber_derivative.as_vector(), - *p4_port3_remote_port3_derivative.as_vector(), - *p4_port3_far_primary_derivative.as_vector(), - *p4_port3_far_chamber_derivative.as_vector(), - *p4_port3_far_port3_derivative.as_vector(), - *p4_port3_next_primary_derivative.as_vector(), - *p4_port3_next_chamber_derivative.as_vector(), - *p4_port3_next_port3_derivative.as_vector(), - *p4_port3_remote_resistance_derivative_1.as_vector(), - *p4_port3_remote_resistance_derivative_2.as_vector(), - *p4_port3_remote_resistance_output_derivative.as_vector(), - *p4_port3_remote_orifice_derivative_1.as_vector(), - *p4_port3_remote_orifice_derivative_2.as_vector(), - *p4_port3_next_orifice_derivative_1.as_vector(), - *p4_port3_next_orifice_derivative_2.as_vector(), - *p4_port3_next_orifice_output_derivative.as_vector(), - *p4_port3_next_chamber_inlet_derivative.as_vector(), - *p4_port3_next_fixed_chamber_derivative.as_vector(), - *p4_port3_next_chamber_outlet_derivative.as_vector(), - *p4_port3_next_resistance_derivative_1.as_vector(), - *p4_port3_next_resistance_derivative_2.as_vector(), - *p4_port3_next_resistance_output_derivative.as_vector(), - *p4_port3_next_resistance_chamber_inlet_derivative.as_vector(), - *p4_port3_next_resistance_fixed_chamber_derivative.as_vector(), - *p4_port3_next_resistance_chamber_outlet_derivative.as_vector(), - *p4_port1_next_orifice_derivative_1.as_vector(), - *p4_port1_next_orifice_derivative_2.as_vector(), - *p4_port1_next_orifice_output_derivative.as_vector(), - *p4_port1_next_orifice_resistance_derivative_1.as_vector(), - *p4_port1_next_orifice_resistance_derivative_2.as_vector(), - *p4_port1_next_orifice_resistance_chamber_inlet_derivative.as_vector(), - *p4_port1_next_orifice_resistance_fixed_chamber_derivative.as_vector(), - *p4_port1_next_orifice_resistance_chamber_outlet_derivative.as_vector(), - ] - - -@dataclass(frozen=True) -class TestMqlPneumaticBranchComponents: - name: str - upstream_volume: AmesimPneumaticVolume - orifice: AmesimPneumaticOrifice - downstream_volume: AmesimPneumaticVolume - spec: TestMqlPneumaticBranchSpec | None = None - - -@dataclass(frozen=True) -class TestMqlPneumaticBranchState: - name: str - upstream: ThermodynamicProperties - downstream: ThermodynamicProperties - flow: float - upstream_inlet_h: float - downstream_inlet_h: float - - -@dataclass(frozen=True) -class TestMqlPneumaticSnapshot: - branch: TestMqlPneumaticBranchState - - @property - def flow(self) -> float: - return self.branch.flow - - @property - def upstream(self) -> ThermodynamicProperties: - return self.branch.upstream - - @property - def downstream(self) -> ThermodynamicProperties: - return self.branch.downstream - - -class TestMqlPneumaticClosure: - """Minimal test_mql pneumatic closure following the existing Testmodel pattern. - - The canonical branch flow is positive from ``upstream_volume`` through the - orifice port_a/port_b into ``downstream_volume``. Port ``m_flow`` values are - written with Modelica-style signs: positive means flow into that component. - """ - - def __init__( - self, - *, - components: TestMqlPneumaticBranchComponents, - initial_state_vector: Callable[[], list[float]], - apply_state_vector: Callable[[list[float]], None], - ) -> None: - self.components = components - self._initial_state_vector = initial_state_vector - self._apply_state_vector = apply_state_vector - - def initial_state_vector(self) -> list[float]: - return self._initial_state_vector() - - def apply_state_vector(self, values: list[float]) -> None: - self._apply_state_vector(values) - - def snapshot( - self, - state_vector: list[float] | None = None, - ) -> TestMqlPneumaticSnapshot: - if state_vector is not None: - self._apply_state_vector(state_vector) - upstream = self.components.upstream_volume.properties() - downstream = self.components.downstream_volume.properties() - flow = self._solve_branch_flow(upstream, downstream) - upstream_inlet_h = self.components.upstream_volume.connection_inlet_enthalpy( - port_m_flow=-flow, - connected_h=downstream.h, - internal_h=upstream.h, - ) - downstream_inlet_h = self.components.downstream_volume.connection_inlet_enthalpy( - port_m_flow=flow, - connected_h=upstream.h, - internal_h=downstream.h, - ) - branch = TestMqlPneumaticBranchState( - name=self.components.name, - upstream=upstream, - downstream=downstream, - flow=flow, - upstream_inlet_h=upstream_inlet_h, - downstream_inlet_h=downstream_inlet_h, - ) - self._write_port_states(branch) - return TestMqlPneumaticSnapshot(branch=branch) - - def _solve_branch_flow( - self, - upstream: ThermodynamicProperties, - downstream: ThermodynamicProperties, - ) -> float: - upstream_temperature = upstream.T if upstream.p >= downstream.p else downstream.T - return self.components.orifice.mass_flow( - upstream.p, - downstream.p, - upstream_temperature, - ) - - def _write_port_states(self, branch: TestMqlPneumaticBranchState) -> None: - upstream_volume = self.components.upstream_volume - downstream_volume = self.components.downstream_volume - orifice = self.components.orifice - - upstream_volume.port_a.p = branch.upstream.p - upstream_volume.port_a.m_flow = -branch.flow - upstream_volume.port_a.h_outflow = branch.upstream.h - - orifice.port_a.p = branch.upstream.p - orifice.port_a.m_flow = branch.flow - orifice.port_a.h_outflow = branch.upstream.h - orifice.port_b.p = branch.downstream.p - orifice.port_b.m_flow = -branch.flow - orifice.port_b.h_outflow = branch.downstream.h - - downstream_volume.port_a.p = branch.downstream.p - downstream_volume.port_a.m_flow = branch.flow - downstream_volume.port_a.h_outflow = branch.downstream.h - - def branch_derivatives( - self, - snapshot: TestMqlPneumaticSnapshot, - ) -> tuple[VolumeState, VolumeState]: - flow = snapshot.flow - upstream_derivative = self.components.upstream_volume.derivatives( - inlet_h=snapshot.branch.upstream_inlet_h, - m_flow=-flow, - ) - downstream_derivative = self.components.downstream_volume.derivatives( - inlet_h=snapshot.branch.downstream_inlet_h, - m_flow=flow, - ) - return upstream_derivative, downstream_derivative - - def rhs(self, state_vector: list[float]) -> list[float]: - snapshot = self.snapshot(state_vector) - upstream_derivative, downstream_derivative = self.branch_derivatives(snapshot) - return [ - *upstream_derivative.as_vector(), - *downstream_derivative.as_vector(), - ] - - -class TestMqlPneumaticChamberSegmentClosure: - """Boundary-reduced fixed chamber segment discovered from AMESim topology. - - The two PNL0001 lines remain prescribed boundary interfaces in this first - closure. Their aliases are retained in the spec so line dynamics can be - inserted later without rediscovering the component path. - """ - - def __init__( - self, - *, - components: TestMqlPneumaticChamberSegmentComponents, - inlet_boundary: TestMqlPneumaticBoundaryCondition, - outlet_boundary: TestMqlPneumaticBoundaryCondition, - ) -> None: - self.components = components - self.inlet_boundary = inlet_boundary - self.outlet_boundary = outlet_boundary - - def initial_state_vector(self) -> list[float]: - return self.components.volume.get_state_vector() - - def apply_state_vector(self, values: list[float]) -> None: - if len(values) != 2: - raise ValueError("single-chamber segment state vector requires two values") - self.components.volume.set_state_vector(values) - - def snapshot( - self, - state_vector: list[float] | None = None, - ) -> TestMqlPneumaticChamberSegmentSnapshot: - if state_vector is not None: - self.apply_state_vector(state_vector) - chamber = self.components.volume.properties() - inlet_boundary = self.inlet_boundary.properties(self.components.volume.gas) - outlet_boundary = self.outlet_boundary.properties(self.components.volume.gas) - inlet_temperature = ( - inlet_boundary.T if inlet_boundary.p >= chamber.p else chamber.T - ) - outlet_temperature = ( - chamber.T if chamber.p >= outlet_boundary.p else outlet_boundary.T - ) - inlet_flow = self.components.inlet_orifice.mass_flow( - inlet_boundary.p, - chamber.p, - inlet_temperature, - ) - outlet_flow = self.components.outlet_orifice.mass_flow( - chamber.p, - outlet_boundary.p, - outlet_temperature, - ) - snapshot = TestMqlPneumaticChamberSegmentSnapshot( - chamber=chamber, - inlet_boundary=inlet_boundary, - outlet_boundary=outlet_boundary, - inlet_flow=inlet_flow, - outlet_flow=outlet_flow, - ) - self._write_port_states(snapshot) - return snapshot - - @staticmethod - def _port( - component: AmesimPneumaticVolume | AmesimPneumaticOrifice, - port_name: str, - ) -> PortState: - if port_name == "port_1": - return component.port_a - if port_name == "port_2": - return component.port_b - raise ValueError(f"unsupported pneumatic port: {port_name}") - - def _write_port_states( - self, - snapshot: TestMqlPneumaticChamberSegmentSnapshot, - ) -> None: - spec = self.components.spec - chamber = self.components.volume - inlet_orifice = self.components.inlet_orifice - outlet_orifice = self.components.outlet_orifice - - inlet_boundary_port = self._port( - inlet_orifice, - spec.inlet_orifice_boundary_port, - ) - inlet_volume_port = self._port(inlet_orifice, spec.inlet_orifice_volume_port) - inlet_boundary_port.p = snapshot.inlet_boundary.p - inlet_boundary_port.m_flow = snapshot.inlet_flow - inlet_boundary_port.h_outflow = snapshot.inlet_boundary.h - inlet_volume_port.p = snapshot.chamber.p - inlet_volume_port.m_flow = -snapshot.inlet_flow - inlet_volume_port.h_outflow = snapshot.chamber.h - - chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) - chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) - chamber_inlet_port.m_flow = snapshot.inlet_flow - chamber_outlet_port.m_flow = -snapshot.outlet_flow - - outlet_volume_port = self._port( - outlet_orifice, - spec.outlet_orifice_volume_port, - ) - outlet_boundary_port = self._port( - outlet_orifice, - spec.outlet_orifice_boundary_port, - ) - outlet_volume_port.p = snapshot.chamber.p - outlet_volume_port.m_flow = snapshot.outlet_flow - outlet_volume_port.h_outflow = snapshot.chamber.h - outlet_boundary_port.p = snapshot.outlet_boundary.p - outlet_boundary_port.m_flow = -snapshot.outlet_flow - outlet_boundary_port.h_outflow = snapshot.outlet_boundary.h - - def rhs(self, state_vector: list[float]) -> list[float]: - snapshot = self.snapshot(state_vector) - spec = self.components.spec - chamber = self.components.volume - derivative = chamber.derivatives_from_two_connections( - port_a_m_flow=self._port(chamber, "port_1").m_flow, - connected_h_a=( - snapshot.inlet_boundary.h - if spec.volume_inlet_port == "port_1" - else snapshot.outlet_boundary.h - ), - port_b_m_flow=self._port(chamber, "port_2").m_flow, - connected_h_b=( - snapshot.inlet_boundary.h - if spec.volume_inlet_port == "port_2" - else snapshot.outlet_boundary.h - ), - internal_h=snapshot.chamber.h, - ) - return derivative.as_vector() - - -class TestMqlPnl0001ChamberSegmentClosure: - """Fixed chamber segment with the topology-derived inlet PNL0001 state. - - The inlet line has a closed causal boundary here: port-1 pressure and - temperature come from the PN3 node boundary, while port-2 flow comes from - the fixed orifice. The outlet line remains a boundary until its three-line - PN3 node balance is assembled. - """ - - def __init__( - self, - *, - components: TestMqlPnl0001ChamberSegmentComponents, - inlet_node: TestMqlPneumaticBoundaryCondition, - outlet_boundary: TestMqlPneumaticBoundaryCondition, - ) -> None: - self.components = components - self.inlet_node = inlet_node - self.outlet_boundary = outlet_boundary - - def initial_state_vector(self) -> list[float]: - return [ - *self.components.inlet_line.get_state_vector(), - *self.components.volume.get_state_vector(), - ] - - def apply_state_vector(self, values: list[float]) -> None: - if len(values) != 4: - raise ValueError("PNL0001/chamber segment state vector requires four values") - self.components.inlet_line.set_state_vector(values[:2]) - self.components.volume.set_state_vector(values[2:]) - - def snapshot( - self, - state_vector: list[float] | None = None, - ) -> TestMqlPnl0001ChamberSegmentSnapshot: - if state_vector is not None: - self.apply_state_vector(state_vector) - line = self.components.inlet_line.properties() - chamber = self.components.volume.properties() - inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) - outlet_boundary = self.outlet_boundary.properties(self.components.volume.gas) - node_to_line_flow = self.components.inlet_line.resistance_mass_flow( - port_1_pressure_pa=inlet_node.p, - port_1_temperature_k=inlet_node.T, - ) - inlet_temperature = line.T if line.p >= chamber.p else chamber.T - line_to_chamber_flow = self.components.inlet_orifice.mass_flow( - line.p, - chamber.p, - inlet_temperature, - ) - outlet_temperature = ( - chamber.T if chamber.p >= outlet_boundary.p else outlet_boundary.T - ) - outlet_flow = self.components.outlet_orifice.mass_flow( - chamber.p, - outlet_boundary.p, - outlet_temperature, - ) - snapshot = TestMqlPnl0001ChamberSegmentSnapshot( - inlet_line=line, - chamber=chamber, - inlet_node=inlet_node, - outlet_boundary=outlet_boundary, - node_to_line_flow=node_to_line_flow, - line_to_chamber_flow=line_to_chamber_flow, - outlet_flow=outlet_flow, - ) - self._write_port_states(snapshot) - return snapshot - - @staticmethod - def _line_to_p4_flow( - *, - line: ThermodynamicProperties, - p4_boundary: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T - return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) - - @staticmethod - def _port( - component: AmesimPneumaticVolume | AmesimPneumaticOrifice, - port_name: str, - ) -> PortState: - return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) - - def _write_port_states( - self, - snapshot: TestMqlPnl0001ChamberSegmentSnapshot, - ) -> None: - spec = self.components.spec - line = self.components.inlet_line - chamber = self.components.volume - inlet_orifice = self.components.inlet_orifice - outlet_orifice = self.components.outlet_orifice - - line.port_1.p = snapshot.inlet_node.p - line.port_1.m_flow = snapshot.node_to_line_flow - line.port_1.h_outflow = snapshot.inlet_line.h - line.port_2.p = snapshot.inlet_line.p - line.port_2.m_flow = -snapshot.line_to_chamber_flow - - inlet_boundary_port = self._port( - inlet_orifice, - spec.inlet_orifice_boundary_port, - ) - inlet_volume_port = self._port(inlet_orifice, spec.inlet_orifice_volume_port) - inlet_boundary_port.p = snapshot.inlet_line.p - inlet_boundary_port.m_flow = snapshot.line_to_chamber_flow - inlet_boundary_port.h_outflow = snapshot.inlet_line.h - inlet_volume_port.p = snapshot.chamber.p - inlet_volume_port.m_flow = -snapshot.line_to_chamber_flow - inlet_volume_port.h_outflow = snapshot.chamber.h - - chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) - chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) - chamber_inlet_port.m_flow = snapshot.line_to_chamber_flow - chamber_outlet_port.m_flow = -snapshot.outlet_flow - - outlet_volume_port = self._port( - outlet_orifice, - spec.outlet_orifice_volume_port, - ) - outlet_boundary_port = self._port( - outlet_orifice, - spec.outlet_orifice_boundary_port, - ) - outlet_volume_port.p = snapshot.chamber.p - outlet_volume_port.m_flow = snapshot.outlet_flow - outlet_volume_port.h_outflow = snapshot.chamber.h - outlet_boundary_port.p = snapshot.outlet_boundary.p - outlet_boundary_port.m_flow = -snapshot.outlet_flow - outlet_boundary_port.h_outflow = snapshot.outlet_boundary.h - - def rhs(self, state_vector: list[float]) -> list[float]: - snapshot = self.snapshot(state_vector) - line_derivative = self.components.inlet_line.derivatives_from_connections( - port_1_m_flow=snapshot.node_to_line_flow, - connected_h_1=snapshot.inlet_node.h, - port_2_m_flow=-snapshot.line_to_chamber_flow, - connected_h_2=snapshot.chamber.h, - ) - chamber = self.components.volume - spec = self.components.spec - chamber_derivative = chamber.derivatives_from_two_connections( - port_a_m_flow=self._port(chamber, "port_1").m_flow, - connected_h_a=( - snapshot.inlet_line.h - if spec.volume_inlet_port == "port_1" - else snapshot.outlet_boundary.h - ), - port_b_m_flow=self._port(chamber, "port_2").m_flow, - connected_h_b=( - snapshot.inlet_line.h - if spec.volume_inlet_port == "port_2" - else snapshot.outlet_boundary.h - ), - internal_h=snapshot.chamber.h, - ) - return [*line_derivative.as_vector(), *chamber_derivative.as_vector()] - - -class TestMqlPnl0001PairChamberSegmentClosure: - """Six-state fixed chamber segment with both PNL0001 compliance states. - - Generated C shows that both line instances receive ``t1/p1`` from their - PN3 nodes and send their explicit ``t2/p2`` states to the fixed orifices. - The reduced closure therefore prescribes node pressure/temperature at both - resistive port-1 boundaries while keeping both line storage states native. - """ - - def __init__( - self, - *, - components: TestMqlPnl0001PairChamberSegmentComponents, - inlet_node: TestMqlPneumaticBoundaryCondition, - outlet_node: TestMqlPneumaticBoundaryCondition, - ) -> None: - self.components = components - self.inlet_node = inlet_node - self.outlet_node = outlet_node - - def initial_state_vector(self) -> list[float]: - return [ - *self.components.inlet_line.get_state_vector(), - *self.components.volume.get_state_vector(), - *self.components.outlet_line.get_state_vector(), - ] - - def apply_state_vector(self, values: list[float]) -> None: - if len(values) != 6: - raise ValueError("PNL0001 pair/chamber state vector requires six values") - self.components.inlet_line.set_state_vector(values[:2]) - self.components.volume.set_state_vector(values[2:4]) - self.components.outlet_line.set_state_vector(values[4:]) - - def snapshot( - self, - state_vector: list[float] | None = None, - ) -> TestMqlPnl0001PairChamberSegmentSnapshot: - if state_vector is not None: - self.apply_state_vector(state_vector) - inlet_line = self.components.inlet_line.properties() - chamber = self.components.volume.properties() - outlet_line = self.components.outlet_line.properties() - inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) - outlet_node = self.outlet_node.properties(self.components.outlet_line.gas) - inlet_node_to_line_flow = self.components.inlet_line.resistance_mass_flow( - port_1_pressure_pa=inlet_node.p, - port_1_temperature_k=inlet_node.T, - ) - outlet_node_to_line_flow = self.components.outlet_line.resistance_mass_flow( - port_1_pressure_pa=outlet_node.p, - port_1_temperature_k=outlet_node.T, - ) - inlet_line_to_chamber_flow = self._line_to_chamber_flow( - line=inlet_line, - chamber=chamber, - orifice=self.components.inlet_orifice, - ) - outlet_line_to_chamber_flow = self._line_to_chamber_flow( - line=outlet_line, - chamber=chamber, - orifice=self.components.outlet_orifice, - ) - snapshot = TestMqlPnl0001PairChamberSegmentSnapshot( - inlet_line=inlet_line, - chamber=chamber, - outlet_line=outlet_line, - inlet_node=inlet_node, - outlet_node=outlet_node, - inlet_node_to_line_flow=inlet_node_to_line_flow, - inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, - outlet_node_to_line_flow=outlet_node_to_line_flow, - outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, - ) - self._write_port_states(snapshot) - return snapshot - - @staticmethod - def _line_to_chamber_flow( - *, - line: ThermodynamicProperties, - chamber: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - upstream_temperature = line.T if line.p >= chamber.p else chamber.T - return orifice.mass_flow(line.p, chamber.p, upstream_temperature) - - @staticmethod - def _line_to_p4_flow( - *, - line: ThermodynamicProperties, - p4_boundary: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T - return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) - - @staticmethod - def _port( - component: AmesimPneumaticVolume | AmesimPneumaticOrifice, - port_name: str, - ) -> PortState: - return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) - - def _write_port_states( - self, - snapshot: TestMqlPnl0001PairChamberSegmentSnapshot, - ) -> None: - spec = self.components.spec - chamber = self.components.volume - chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) - chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) - - self._write_line_side( - line=self.components.inlet_line, - line_properties=snapshot.inlet_line, - node_properties=snapshot.inlet_node, - node_to_line_flow=snapshot.inlet_node_to_line_flow, - line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, - orifice=self.components.inlet_orifice, - orifice_boundary_port=spec.inlet_orifice_boundary_port, - orifice_volume_port=spec.inlet_orifice_volume_port, - chamber_port=chamber_inlet_port, - chamber_properties=snapshot.chamber, - ) - self._write_line_side( - line=self.components.outlet_line, - line_properties=snapshot.outlet_line, - node_properties=snapshot.outlet_node, - node_to_line_flow=snapshot.outlet_node_to_line_flow, - line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, - orifice=self.components.outlet_orifice, - orifice_boundary_port=spec.outlet_orifice_boundary_port, - orifice_volume_port=spec.outlet_orifice_volume_port, - chamber_port=chamber_outlet_port, - chamber_properties=snapshot.chamber, - ) - - def _write_line_side( - self, - *, - line: AmesimPnl0001Pipe, - line_properties: ThermodynamicProperties, - node_properties: ThermodynamicProperties, - node_to_line_flow: float, - line_to_chamber_flow: float, - orifice: AmesimPneumaticOrifice, - orifice_boundary_port: str, - orifice_volume_port: str, - chamber_port: PortState, - chamber_properties: ThermodynamicProperties, - ) -> None: - line.port_1.p = node_properties.p - line.port_1.m_flow = node_to_line_flow - line.port_1.h_outflow = line_properties.h - line.port_2.p = line_properties.p - line.port_2.m_flow = -line_to_chamber_flow - - boundary_port = self._port(orifice, orifice_boundary_port) - volume_port = self._port(orifice, orifice_volume_port) - boundary_port.p = line_properties.p - boundary_port.m_flow = line_to_chamber_flow - boundary_port.h_outflow = line_properties.h - volume_port.p = chamber_properties.p - volume_port.m_flow = -line_to_chamber_flow - volume_port.h_outflow = chamber_properties.h - chamber_port.m_flow = line_to_chamber_flow - - def rhs(self, state_vector: list[float]) -> list[float]: - snapshot = self.snapshot(state_vector) - inlet_derivative = self.components.inlet_line.derivatives_from_connections( - port_1_m_flow=snapshot.inlet_node_to_line_flow, - connected_h_1=snapshot.inlet_node.h, - port_2_m_flow=-snapshot.inlet_line_to_chamber_flow, - connected_h_2=snapshot.chamber.h, - ) - outlet_derivative = self.components.outlet_line.derivatives_from_connections( - port_1_m_flow=snapshot.outlet_node_to_line_flow, - connected_h_1=snapshot.outlet_node.h, - port_2_m_flow=-snapshot.outlet_line_to_chamber_flow, - connected_h_2=snapshot.chamber.h, - ) - chamber = self.components.volume - spec = self.components.spec - chamber_derivative = chamber.derivatives_from_two_connections( - port_a_m_flow=self._port(chamber, "port_1").m_flow, - connected_h_a=( - snapshot.inlet_line.h - if spec.volume_inlet_port == "port_1" - else snapshot.outlet_line.h - ), - port_b_m_flow=self._port(chamber, "port_2").m_flow, - connected_h_b=( - snapshot.inlet_line.h - if spec.volume_inlet_port == "port_2" - else snapshot.outlet_line.h - ), - internal_h=snapshot.chamber.h, - ) - return [ - *inlet_derivative.as_vector(), - *chamber_derivative.as_vector(), - *outlet_derivative.as_vector(), - ] - - -class TestMqlPn3NodeChamberSegmentClosure: - """Boundary-reduced chamber segment closed by one real PN3 node. - - ``pneumatic_88`` supplies the PN3 primary pressure/temperature from its - port-1 compliance. The node has no storage, so the sum of the port-1 - PNL00R flow and port-3 PNL0001 flow is written as the opposite port-1 - flow into the PNL0003 state. - """ - - def __init__( - self, - *, - components: TestMqlPn3NodeChamberSegmentComponents, - inlet_node: TestMqlPneumaticBoundaryCondition, - resistance_boundary: TestMqlPneumaticBoundaryCondition, - p4_boundary: TestMqlPneumaticBoundaryCondition, - ) -> None: - self.components = components - self.inlet_node = inlet_node - self.resistance_boundary = resistance_boundary - self.p4_boundary = p4_boundary - - def initial_state_vector(self) -> list[float]: - return [ - *self.components.inlet_line.get_state_vector(), - *self.components.volume.get_state_vector(), - *self.components.outlet_line.get_state_vector(), - *self.components.node_line.get_state_vector(), - ] - - def apply_state_vector(self, values: list[float]) -> None: - if len(values) != 10: - raise ValueError("PN3 node/chamber segment state vector requires ten values") - self.components.inlet_line.set_state_vector(values[:2]) - self.components.volume.set_state_vector(values[2:4]) - self.components.outlet_line.set_state_vector(values[4:6]) - self.components.node_line.set_state_vector(values[6:]) - - def snapshot( - self, - state_vector: list[float] | None = None, - ) -> TestMqlPn3NodeChamberSegmentSnapshot: - if state_vector is not None: - self.apply_state_vector(state_vector) - inlet_line = self.components.inlet_line.properties() - chamber = self.components.volume.properties() - outlet_line = self.components.outlet_line.properties() - node_line_port_1 = self.components.node_line.properties_1() - node_line_port_2 = self.components.node_line.properties_2() - inlet_node = self.inlet_node.properties(self.components.inlet_line.gas) - resistance_boundary = self.resistance_boundary.properties( - self.components.node_resistance.gas - ) - p4_boundary = self.p4_boundary.properties(self.components.node_line.gas) - inlet_node_to_line_flow = self.components.inlet_line.resistance_mass_flow( - port_1_pressure_pa=inlet_node.p, - port_1_temperature_k=inlet_node.T, - ) - outlet_node_to_line_flow = self.components.outlet_line.resistance_mass_flow( - port_1_pressure_pa=node_line_port_1.p, - port_1_temperature_k=node_line_port_1.T, - ) - inlet_line_to_chamber_flow = self._line_to_chamber_flow( - line=inlet_line, - chamber=chamber, - orifice=self.components.inlet_orifice, - ) - outlet_line_to_chamber_flow = self._line_to_chamber_flow( - line=outlet_line, - chamber=chamber, - orifice=self.components.outlet_orifice, - ) - node_to_resistance_flow = self.components.node_resistance.mass_flow( - port_1_pressure_pa=node_line_port_1.p, - port_1_temperature_k=node_line_port_1.T, - port_2_pressure_pa=resistance_boundary.p, - port_2_temperature_k=resistance_boundary.T, - ) - node_balance = self.components.node.balance( - port_2_temperature_k=node_line_port_1.T, - port_2_pressure_pa=node_line_port_1.p, - port_1_enthalpy_flow_w=self._enthalpy_flow_from_node( - flow_kg_s=node_to_resistance_flow, - node_h=node_line_port_1.h, - connected_h=resistance_boundary.h, - ), - port_1_mass_flow_g_s=node_to_resistance_flow * 1.0e3, - port_3_enthalpy_flow_w=self._enthalpy_flow_from_node( - flow_kg_s=outlet_node_to_line_flow, - node_h=node_line_port_1.h, - connected_h=outlet_line.h, - ), - port_3_mass_flow_g_s=outlet_node_to_line_flow * 1.0e3, - ) - node_to_pnl0003_flow = -node_balance.port_2_mass_flow_g_s * 1.0e-3 - pnl0003_center_flow = self.components.node_line.resistance_mass_flow() - pnl0003_to_p4_flow = self._line_to_p4_flow( - line=node_line_port_2, - p4_boundary=p4_boundary, - orifice=self.components.node_orifice, - ) - snapshot = TestMqlPn3NodeChamberSegmentSnapshot( - inlet_line=inlet_line, - chamber=chamber, - outlet_line=outlet_line, - node_line_port_1=node_line_port_1, - node_line_port_2=node_line_port_2, - inlet_node=inlet_node, - resistance_boundary=resistance_boundary, - p4_boundary=p4_boundary, - node_balance=node_balance, - inlet_node_to_line_flow=inlet_node_to_line_flow, - inlet_line_to_chamber_flow=inlet_line_to_chamber_flow, - outlet_node_to_line_flow=outlet_node_to_line_flow, - outlet_line_to_chamber_flow=outlet_line_to_chamber_flow, - node_to_resistance_flow=node_to_resistance_flow, - node_to_pnl0003_flow=node_to_pnl0003_flow, - pnl0003_center_flow=pnl0003_center_flow, - pnl0003_to_p4_flow=pnl0003_to_p4_flow, - ) - self._write_port_states(snapshot) - return snapshot - - @staticmethod - def _line_to_chamber_flow( - *, - line: ThermodynamicProperties, - chamber: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - return TestMqlPnl0001PairChamberSegmentClosure._line_to_chamber_flow( - line=line, - chamber=chamber, - orifice=orifice, - ) - - @staticmethod - def _line_to_p4_flow( - *, - line: ThermodynamicProperties, - p4_boundary: ThermodynamicProperties, - orifice: AmesimPneumaticOrifice, - ) -> float: - upstream_temperature = line.T if line.p >= p4_boundary.p else p4_boundary.T - return orifice.mass_flow(line.p, p4_boundary.p, upstream_temperature) - - @staticmethod - def _port( - component: AmesimPneumaticVolume | AmesimPneumaticOrifice, - port_name: str, - ) -> PortState: - return TestMqlPneumaticChamberSegmentClosure._port(component, port_name) - - @staticmethod - def _enthalpy_flow_from_node( - *, - flow_kg_s: float, - node_h: float, - connected_h: float, - ) -> float: - return flow_kg_s * (node_h if flow_kg_s >= 0.0 else connected_h) - - def _write_port_states( - self, - snapshot: TestMqlPn3NodeChamberSegmentSnapshot, - ) -> None: - spec = self.components.spec - chamber = self.components.volume - chamber_inlet_port = self._port(chamber, spec.volume_inlet_port) - chamber_outlet_port = self._port(chamber, spec.volume_outlet_port) - - TestMqlPnl0001PairChamberSegmentClosure._write_line_side( - self, - line=self.components.inlet_line, - line_properties=snapshot.inlet_line, - node_properties=snapshot.inlet_node, - node_to_line_flow=snapshot.inlet_node_to_line_flow, - line_to_chamber_flow=snapshot.inlet_line_to_chamber_flow, - orifice=self.components.inlet_orifice, - orifice_boundary_port=spec.inlet_orifice_boundary_port, - orifice_volume_port=spec.inlet_orifice_volume_port, - chamber_port=chamber_inlet_port, - chamber_properties=snapshot.chamber, - ) - TestMqlPnl0001PairChamberSegmentClosure._write_line_side( - self, - line=self.components.outlet_line, - line_properties=snapshot.outlet_line, - node_properties=snapshot.node_line_port_1, - node_to_line_flow=snapshot.outlet_node_to_line_flow, - line_to_chamber_flow=snapshot.outlet_line_to_chamber_flow, - orifice=self.components.outlet_orifice, - orifice_boundary_port=spec.outlet_orifice_boundary_port, - orifice_volume_port=spec.outlet_orifice_volume_port, - chamber_port=chamber_outlet_port, - chamber_properties=snapshot.chamber, - ) - node_line = self.components.node_line - node_line.port_1.p = snapshot.node_line_port_1.p - node_line.port_1.m_flow = snapshot.node_to_pnl0003_flow - node_line.port_1.h_outflow = snapshot.node_line_port_1.h - node_line.port_2.p = snapshot.node_line_port_2.p - node_line.port_2.m_flow = -snapshot.pnl0003_to_p4_flow - node_line.port_2.h_outflow = snapshot.node_line_port_2.h - - node_orifice = self.components.node_orifice - node_orifice.port_a.p = snapshot.node_line_port_2.p - node_orifice.port_a.m_flow = snapshot.pnl0003_to_p4_flow - node_orifice.port_a.h_outflow = snapshot.node_line_port_2.h - node_orifice.port_b.p = snapshot.p4_boundary.p - node_orifice.port_b.m_flow = -snapshot.pnl0003_to_p4_flow - node_orifice.port_b.h_outflow = snapshot.p4_boundary.h - - resistance = self.components.node_resistance - resistance.port_1.p = snapshot.node_line_port_1.p - resistance.port_1.m_flow = snapshot.node_to_resistance_flow - resistance.port_1.h_outflow = snapshot.node_line_port_1.h - resistance.port_2.p = snapshot.resistance_boundary.p - resistance.port_2.m_flow = -snapshot.node_to_resistance_flow - resistance.port_2.h_outflow = snapshot.resistance_boundary.h - - def rhs(self, state_vector: list[float]) -> list[float]: - snapshot = self.snapshot(state_vector) - inlet_derivative = self.components.inlet_line.derivatives_from_connections( - port_1_m_flow=snapshot.inlet_node_to_line_flow, - connected_h_1=snapshot.inlet_node.h, - port_2_m_flow=-snapshot.inlet_line_to_chamber_flow, - connected_h_2=snapshot.chamber.h, - ) - outlet_derivative = self.components.outlet_line.derivatives_from_connections( - port_1_m_flow=snapshot.outlet_node_to_line_flow, - connected_h_1=snapshot.node_line_port_1.h, - port_2_m_flow=-snapshot.outlet_line_to_chamber_flow, - connected_h_2=snapshot.chamber.h, - ) - chamber = self.components.volume - spec = self.components.spec - chamber_derivative = chamber.derivatives_from_two_connections( - port_a_m_flow=self._port(chamber, "port_1").m_flow, - connected_h_a=( - snapshot.inlet_line.h - if spec.volume_inlet_port == "port_1" - else snapshot.outlet_line.h - ), - port_b_m_flow=self._port(chamber, "port_2").m_flow, - connected_h_b=( - snapshot.inlet_line.h - if spec.volume_inlet_port == "port_2" - else snapshot.outlet_line.h - ), - internal_h=snapshot.chamber.h, - ) - node_line_derivative_1, node_line_derivative_2 = ( - self.components.node_line.derivatives_from_connections( - port_1_m_flow=snapshot.node_to_pnl0003_flow, - connected_h_1=snapshot.node_line_port_1.h, - port_2_m_flow=-snapshot.pnl0003_to_p4_flow, - connected_h_2=snapshot.p4_boundary.h, - ) - ) - return [ - *inlet_derivative.as_vector(), - *chamber_derivative.as_vector(), - *outlet_derivative.as_vector(), - *node_line_derivative_1.as_vector(), - *node_line_derivative_2.as_vector(), - ] diff --git a/app/simulation/examples/test_mql/computed.py b/app/simulation/examples/test_mql/computed.py deleted file mode 100644 index 4dd2b79..0000000 --- a/app/simulation/examples/test_mql/computed.py +++ /dev/null @@ -1,468 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -from app.simulation.examples.test_mql.primitives.pneumatic import m3_to_cm3 -from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results -from app.simulation.reporting.test_mql_comparison import TestMqlComparisonResult -from app.simulation.reporting.test_mql_observations import ( - TestMqlObservationCatalog, - build_test_mql_observation_catalog, -) -from app.simulation.reporting.test_mql_output_schema import ( - TestMqlOutputSchema, - build_test_mql_output_schema, -) -from app.simulation.reporting.test_mql_output_validation import ( - TestMqlValidatedOutput, - compare_validated_test_mql_output, - validate_test_mql_output, -) -from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog -from app.simulation.examples.test_mql.mechanical import ( - TestMqlMechanicalAssembly, - build_test_mql_mechanical_assembly, -) -from app.simulation.examples.test_mql.pneumatic import ( - TestMqlPneumaticAssembly, - build_test_mql_pneumatic_assembly, -) - - -@dataclass(frozen=True) -class TestMqlComputedPistonGeometryRun: - amesim_results: AmesimResults - observation_catalog: TestMqlObservationCatalog - output_schema: TestMqlOutputSchema - mechanical_assembly: TestMqlMechanicalAssembly - output: TestMqlValidatedOutput - comparison: TestMqlComparisonResult - - @property - def sample_count(self) -> int: - return len(self.output.times) - - @property - def signal_count(self) -> int: - return len(self.output.data_paths) - - -@dataclass(frozen=True) -class TestMqlComputedGeometryRun: - amesim_results: AmesimResults - observation_catalog: TestMqlObservationCatalog - output_schema: TestMqlOutputSchema - mechanical_assembly: TestMqlMechanicalAssembly - pneumatic_assembly: TestMqlPneumaticAssembly - output: TestMqlValidatedOutput - comparison: TestMqlComparisonResult - - @property - def sample_count(self) -> int: - return len(self.output.times) - - @property - def signal_count(self) -> int: - return len(self.output.data_paths) - - -@dataclass(frozen=True) -class TestMqlComputedLineRelationsRun: - amesim_results: AmesimResults - observation_catalog: TestMqlObservationCatalog - output_schema: TestMqlOutputSchema - output: TestMqlValidatedOutput - comparison: TestMqlComparisonResult - - @property - def sample_count(self) -> int: - return len(self.output.times) - - @property - def signal_count(self) -> int: - return len(self.output.data_paths) - - -@dataclass(frozen=True) -class TestMqlComputedPneumaticRelationsRun: - amesim_results: AmesimResults - observation_catalog: TestMqlObservationCatalog - output_schema: TestMqlOutputSchema - output: TestMqlValidatedOutput - comparison: TestMqlComparisonResult - - @property - def sample_count(self) -> int: - return len(self.output.times) - - @property - def signal_count(self) -> int: - return len(self.output.data_paths) - - -@dataclass(frozen=True) -class TestMqlComputedMechanicalRelationsRun: - amesim_results: AmesimResults - observation_catalog: TestMqlObservationCatalog - output_schema: TestMqlOutputSchema - mechanical_assembly: TestMqlMechanicalAssembly - output: TestMqlValidatedOutput - comparison: TestMqlComparisonResult - - @property - def sample_count(self) -> int: - return len(self.output.times) - - @property - def signal_count(self) -> int: - return len(self.output.data_paths) - - -def run_test_mql_computed_piston_geometry( - archive_path: Path, -) -> TestMqlComputedPistonGeometryRun: - amesim_results = load_test_mql_amesim_results(archive_path) - observation_catalog = build_test_mql_observation_catalog(amesim_results) - output_schema = build_test_mql_output_schema( - amesim_results, - observation_catalog=observation_catalog, - ) - variable_catalog = build_test_mql_variable_catalog(amesim_results) - mechanical_assembly = build_test_mql_mechanical_assembly( - amesim_results=amesim_results, - variable_catalog=variable_catalog, - ) - output_series = _compute_piston_geometry_series(amesim_results, mechanical_assembly) - output_data_paths = tuple(output_series) - output = validate_test_mql_output( - times=amesim_results.times, - series_by_data_path=output_series, - schema=output_schema, - data_paths=output_data_paths, - ) - comparison = compare_validated_test_mql_output( - times=output.times, - series_by_data_path=output.series_by_data_path, - schema=output_schema, - amesim_results=amesim_results, - data_paths=output.data_paths, - ) - return TestMqlComputedPistonGeometryRun( - amesim_results=amesim_results, - observation_catalog=observation_catalog, - output_schema=output_schema, - mechanical_assembly=mechanical_assembly, - output=output, - comparison=comparison, - ) - - -def run_test_mql_computed_geometry( - archive_path: Path, -) -> TestMqlComputedGeometryRun: - amesim_results = load_test_mql_amesim_results(archive_path) - observation_catalog = build_test_mql_observation_catalog(amesim_results) - output_schema = build_test_mql_output_schema( - amesim_results, - observation_catalog=observation_catalog, - ) - variable_catalog = build_test_mql_variable_catalog(amesim_results) - mechanical_assembly = build_test_mql_mechanical_assembly( - amesim_results=amesim_results, - variable_catalog=variable_catalog, - ) - pneumatic_assembly = build_test_mql_pneumatic_assembly() - output_series = { - **_compute_piston_geometry_series(amesim_results, mechanical_assembly), - **_compute_variable_chamber_volume_series( - amesim_results, - mechanical_assembly, - pneumatic_assembly, - ), - } - output_data_paths = tuple(output_series) - output = validate_test_mql_output( - times=amesim_results.times, - series_by_data_path=output_series, - schema=output_schema, - data_paths=output_data_paths, - ) - comparison = compare_validated_test_mql_output( - times=output.times, - series_by_data_path=output.series_by_data_path, - schema=output_schema, - amesim_results=amesim_results, - data_paths=output.data_paths, - ) - return TestMqlComputedGeometryRun( - amesim_results=amesim_results, - observation_catalog=observation_catalog, - output_schema=output_schema, - mechanical_assembly=mechanical_assembly, - pneumatic_assembly=pneumatic_assembly, - output=output, - comparison=comparison, - ) - - -def run_test_mql_computed_line_relations( - archive_path: Path, -) -> TestMqlComputedLineRelationsRun: - amesim_results = load_test_mql_amesim_results(archive_path) - observation_catalog = build_test_mql_observation_catalog(amesim_results) - output_schema = build_test_mql_output_schema( - amesim_results, - observation_catalog=observation_catalog, - ) - output_series = _compute_line_reversed_series(amesim_results, observation_catalog) - output_data_paths = tuple(output_series) - output = validate_test_mql_output( - times=amesim_results.times, - series_by_data_path=output_series, - schema=output_schema, - data_paths=output_data_paths, - ) - comparison = compare_validated_test_mql_output( - times=output.times, - series_by_data_path=output.series_by_data_path, - schema=output_schema, - amesim_results=amesim_results, - data_paths=output.data_paths, - ) - return TestMqlComputedLineRelationsRun( - amesim_results=amesim_results, - observation_catalog=observation_catalog, - output_schema=output_schema, - output=output, - comparison=comparison, - ) - - -def run_test_mql_computed_pneumatic_relations( - archive_path: Path, -) -> TestMqlComputedPneumaticRelationsRun: - amesim_results = load_test_mql_amesim_results(archive_path) - observation_catalog = build_test_mql_observation_catalog(amesim_results) - output_schema = build_test_mql_output_schema( - amesim_results, - observation_catalog=observation_catalog, - ) - output_series = { - **_compute_chamber_duplicate_series(amesim_results, observation_catalog), - **_compute_orifice_reversed_series(amesim_results, observation_catalog), - } - output_data_paths = tuple(output_series) - output = validate_test_mql_output( - times=amesim_results.times, - series_by_data_path=output_series, - schema=output_schema, - data_paths=output_data_paths, - ) - comparison = compare_validated_test_mql_output( - times=output.times, - series_by_data_path=output.series_by_data_path, - schema=output_schema, - amesim_results=amesim_results, - data_paths=output.data_paths, - ) - return TestMqlComputedPneumaticRelationsRun( - amesim_results=amesim_results, - observation_catalog=observation_catalog, - output_schema=output_schema, - output=output, - comparison=comparison, - ) - - -def run_test_mql_computed_mechanical_relations( - archive_path: Path, -) -> TestMqlComputedMechanicalRelationsRun: - amesim_results = load_test_mql_amesim_results(archive_path) - observation_catalog = build_test_mql_observation_catalog(amesim_results) - output_schema = build_test_mql_output_schema( - amesim_results, - observation_catalog=observation_catalog, - ) - variable_catalog = build_test_mql_variable_catalog(amesim_results) - mechanical_assembly = build_test_mql_mechanical_assembly( - amesim_results=amesim_results, - variable_catalog=variable_catalog, - ) - output_series = { - **_compute_mass_duplicate_series(amesim_results, mechanical_assembly), - **_compute_inactive_mass_force_series(amesim_results, mechanical_assembly), - **_compute_zero_force_source_series(amesim_results, mechanical_assembly), - } - output_data_paths = tuple(output_series) - output = validate_test_mql_output( - times=amesim_results.times, - series_by_data_path=output_series, - schema=output_schema, - data_paths=output_data_paths, - ) - comparison = compare_validated_test_mql_output( - times=output.times, - series_by_data_path=output.series_by_data_path, - schema=output_schema, - amesim_results=amesim_results, - data_paths=output.data_paths, - ) - return TestMqlComputedMechanicalRelationsRun( - amesim_results=amesim_results, - observation_catalog=observation_catalog, - output_schema=output_schema, - mechanical_assembly=mechanical_assembly, - output=output, - comparison=comparison, - ) - - -def _compute_piston_geometry_series( - amesim_results: AmesimResults, - mechanical_assembly: TestMqlMechanicalAssembly, -) -> dict[str, tuple[float, ...]]: - series_by_data_path: dict[str, tuple[float, ...]] = {} - for alias in sorted(mechanical_assembly.pistons): - piston = mechanical_assembly.pistons[alias] - geometry = piston.geometry() - x4 = amesim_results.series(f"x4@{alias}") - x5 = amesim_results.series(f"x5@{alias}") - v4 = amesim_results.series(f"v4@{alias}") - v5 = amesim_results.series(f"v5@{alias}") - series_by_data_path[f"length@{alias}"] = tuple( - geometry.chamber_length_mm(port4, port5) - for port4, port5 in zip(x4, x5) - ) - series_by_data_path[f"vol1@{alias}"] = tuple( - geometry.chamber_volume_cm3(port4, port5) - for port4, port5 in zip(x4, x5) - ) - series_by_data_path[f"vvol1@{alias}"] = tuple( - geometry.chamber_volume_rate_l_min(port4, port5) - for port4, port5 in zip(v4, v5) - ) - return series_by_data_path - - -def _compute_variable_chamber_volume_series( - amesim_results: AmesimResults, - mechanical_assembly: TestMqlMechanicalAssembly, - pneumatic_assembly: TestMqlPneumaticAssembly, -) -> dict[str, tuple[float, ...]]: - series_by_data_path: dict[str, tuple[float, ...]] = {} - for chamber_alias in sorted(pneumatic_assembly.variable_chambers): - chamber = pneumatic_assembly.variable_chambers[chamber_alias] - piston_alias = _piston_alias_for_variable_chamber(chamber_alias) - piston = mechanical_assembly.pistons[piston_alias] - geometry = piston.geometry() - x4 = amesim_results.series(f"x4@{piston_alias}") - x5 = amesim_results.series(f"x5@{piston_alias}") - dead_volume_cm3 = m3_to_cm3(chamber.dead_volume) - series_by_data_path[f"vol@{chamber_alias}"] = tuple( - dead_volume_cm3 + geometry.chamber_volume_cm3(port4, port5) - for port4, port5 in zip(x4, x5) - ) - return series_by_data_path - - -def _piston_alias_for_variable_chamber(chamber_alias: str) -> str: - if not chamber_alias.startswith("pn_c1"): - raise ValueError(f"Unexpected PNCH012 alias: {chamber_alias}") - return chamber_alias.replace("pn_c1", "pn_brp2", 1) - - -def _compute_mass_duplicate_series( - amesim_results: AmesimResults, - mechanical_assembly: TestMqlMechanicalAssembly, -) -> dict[str, tuple[float, ...]]: - series_by_data_path: dict[str, tuple[float, ...]] = {} - for alias in sorted(mechanical_assembly.masses): - for signal_name in ("x1", "v1", "acc1"): - source_path = f"{signal_name}@{alias}" - duplicate_path = f"{signal_name}dup@{alias}" - series_by_data_path[duplicate_path] = tuple( - -value for value in amesim_results.series(source_path) - ) - return series_by_data_path - - -def _compute_inactive_mass_force_series( - amesim_results: AmesimResults, - mechanical_assembly: TestMqlMechanicalAssembly, -) -> dict[str, tuple[float, ...]]: - series_by_data_path: dict[str, tuple[float, ...]] = {} - for alias in sorted(mechanical_assembly.masses): - mass = mechanical_assembly.masses[alias].endstop() - x1 = amesim_results.series(f"x1@{alias}") - v1 = amesim_results.series(f"v1@{alias}") - series_by_data_path[f"Fmin@{alias}"] = tuple( - mass.lower_static_force_magnitude(displacement) - for displacement in x1 - ) - series_by_data_path[f"Fvisc@{alias}"] = tuple( - mass.viscous_friction_force(velocity) - for velocity in v1 - ) - series_by_data_path[f"Ffric@{alias}"] = tuple(0.0 for _ in x1) - return series_by_data_path - - -def _compute_zero_force_source_series( - amesim_results: AmesimResults, - mechanical_assembly: TestMqlMechanicalAssembly, -) -> dict[str, tuple[float, ...]]: - return { - f"fzero@{alias}": tuple(0.0 for _ in amesim_results.times) - for alias in sorted(mechanical_assembly.zero_force_sources) - } - - -def _compute_chamber_duplicate_series( - amesim_results: AmesimResults, - observation_catalog: TestMqlObservationCatalog, -) -> dict[str, tuple[float, ...]]: - series_by_data_path: dict[str, tuple[float, ...]] = {} - for binding in observation_catalog.chambers.bindings: - pressure_series = amesim_results.series(binding.pressure_path) - temperature_series = amesim_results.series(binding.temperature_path) - for duplicate_path in binding.pressure_duplicate_paths: - series_by_data_path[duplicate_path] = tuple(pressure_series) - for duplicate_path in binding.temperature_duplicate_paths: - series_by_data_path[duplicate_path] = tuple(temperature_series) - return series_by_data_path - - -def _compute_orifice_reversed_series( - amesim_results: AmesimResults, - observation_catalog: TestMqlObservationCatalog, -) -> dict[str, tuple[float, ...]]: - series_by_data_path: dict[str, tuple[float, ...]] = {} - for binding in observation_catalog.orifices.bindings: - series_by_data_path[binding.reversed_mass_flow_path] = tuple( - -value for value in amesim_results.series(binding.primary_mass_flow_path) - ) - series_by_data_path[binding.reversed_enthalpy_flow_path] = tuple( - -value for value in amesim_results.series(binding.primary_enthalpy_flow_path) - ) - return series_by_data_path - - -def _compute_line_reversed_series( - amesim_results: AmesimResults, - observation_catalog: TestMqlObservationCatalog, -) -> dict[str, tuple[float, ...]]: - series_by_data_path: dict[str, tuple[float, ...]] = {} - for binding in observation_catalog.lines.by_submodel("PNL00R"): - if len(binding.mass_flow_paths) != 2 or len(binding.enthalpy_flow_paths) != 2: - raise ValueError(f"Expected two PNL00R flow paths for {binding.alias}.") - primary_mass_path, reversed_mass_path = binding.mass_flow_paths - primary_enthalpy_path, reversed_enthalpy_path = binding.enthalpy_flow_paths - series_by_data_path[reversed_mass_path] = tuple( - -value for value in amesim_results.series(primary_mass_path) - ) - series_by_data_path[reversed_enthalpy_path] = tuple( - -value for value in amesim_results.series(primary_enthalpy_path) - ) - return series_by_data_path - diff --git a/app/simulation/examples/test_mql/config.py b/app/simulation/examples/test_mql/config.py deleted file mode 100644 index f43d88b..0000000 --- a/app/simulation/examples/test_mql/config.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -import ast -import operator -from dataclasses import dataclass -from math import isfinite -from typing import Any - -from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid -from app.simulation.examples.test_mql.system import COMPONENT_SPECS, GLOBAL_PARAMETERS - - -_BINARY_OPERATORS = { - ast.Add: operator.add, - ast.Sub: operator.sub, - ast.Mult: operator.mul, - ast.Div: operator.truediv, - ast.Pow: operator.pow, -} -_UNARY_OPERATORS = { - ast.UAdd: operator.pos, - ast.USub: operator.neg, -} - - -class TestMqlExpressionError(ValueError): - """Raised when an AMESim parameter expression cannot be resolved safely.""" - - -@dataclass(frozen=True) -class TestMqlResolvedParameter: - name: str - title: str - raw_value: str - units: str - value: float | None - - @property - def is_numeric(self) -> bool: - return self.value is not None - - -@dataclass(frozen=True) -class TestMqlResolvedComponent: - alias: str - component_name: str - submodel: str - label: str - parameters: dict[str, TestMqlResolvedParameter] - - def parameter_value(self, name: str) -> float: - parameter = self.parameters[name] - if parameter.value is None: - raise KeyError(f"Parameter {name!r} on {self.alias!r} is not numeric") - return parameter.value - - -@dataclass(frozen=True) -class TestMqlConfig: - raw_global_parameters: dict[str, str] - global_parameters: dict[str, float] - fluid: PengRobinsonFluid - components: tuple[TestMqlResolvedComponent, ...] - - @classmethod - def from_amesim_specs(cls) -> "TestMqlConfig": - raw_globals = dict(GLOBAL_PARAMETERS) - numeric_globals = { - name: value - for name, raw in raw_globals.items() - if (value := resolve_numeric_expression(raw, {})) is not None - } - components = tuple( - _resolve_component(spec, numeric_globals) - for spec in COMPONENT_SPECS - ) - return cls( - raw_global_parameters=raw_globals, - global_parameters=numeric_globals, - fluid=HELIUM_PR, - components=components, - ) - - def component(self, alias: str) -> TestMqlResolvedComponent: - for component in self.components: - if component.alias == alias: - return component - raise KeyError(alias) - - def components_by_submodel(self, submodel: str) -> tuple[TestMqlResolvedComponent, ...]: - return tuple(component for component in self.components if component.submodel == submodel) - - -def _resolve_component( - spec: dict[str, Any], - variables: dict[str, float], -) -> TestMqlResolvedComponent: - parameters = {} - for parameter in spec.get("parameters", []): - name = str(parameter["name"]) - raw_value = str(parameter["value"]) - parameters[name] = TestMqlResolvedParameter( - name=name, - title=str(parameter["title"]), - raw_value=raw_value, - units=str(parameter["units"]), - value=resolve_numeric_expression(raw_value, variables), - ) - return TestMqlResolvedComponent( - alias=str(spec["alias"]), - component_name=str(spec["component_name"]), - submodel=str(spec["submodel"]), - label=str(spec["label"]), - parameters=parameters, - ) - - -def resolve_numeric_expression( - expression: str, - variables: dict[str, float], -) -> float | None: - expression = expression.strip() - if not expression: - return None - normalized = expression.replace("^", "**") - try: - parsed = ast.parse(normalized, mode="eval") - value = float(_eval_node(parsed.body, variables)) - except (SyntaxError, TestMqlExpressionError, ValueError, TypeError, ZeroDivisionError): - return None - return value if isfinite(value) else None - - -def _eval_node(node: ast.AST, variables: dict[str, float]) -> float: - if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): - return float(node.value) - if isinstance(node, ast.Name): - if node.id not in variables: - raise TestMqlExpressionError(f"Unknown variable: {node.id}") - return float(variables[node.id]) - if isinstance(node, ast.BinOp): - operator_type = type(node.op) - if operator_type not in _BINARY_OPERATORS: - raise TestMqlExpressionError(f"Unsupported binary operator: {operator_type}") - return float(_BINARY_OPERATORS[operator_type](_eval_node(node.left, variables), _eval_node(node.right, variables))) - if isinstance(node, ast.UnaryOp): - operator_type = type(node.op) - if operator_type not in _UNARY_OPERATORS: - raise TestMqlExpressionError(f"Unsupported unary operator: {operator_type}") - return float(_UNARY_OPERATORS[operator_type](_eval_node(node.operand, variables))) - raise TestMqlExpressionError(f"Unsupported expression node: {type(node)}") diff --git a/app/simulation/examples/test_mql/line_parameters.py b/app/simulation/examples/test_mql/line_parameters.py deleted file mode 100644 index 1632f37..0000000 --- a/app/simulation/examples/test_mql/line_parameters.py +++ /dev/null @@ -1,451 +0,0 @@ -from __future__ import annotations - -import re -import tarfile -from dataclasses import dataclass -from pathlib import Path - -from app.simulation.examples.test_mql.system import CONNECTION_SPECS, GLOBAL_PARAMETERS -from app.simulation.examples.test_mql.config import resolve_numeric_expression - - -AMESIM_REFERENCE_PRESSURE_PA = 101_300.0 - - -@dataclass(frozen=True) -class TestMqlPnl0001Spec: - alias: str - source_component: str - source_port: str - target_component: str - target_port: str - diameter_mm: float - length_m: float - relative_roughness: float - polytropic_constant: float - heat_transfer_coefficient: float - external_temperature_k: float - gas_type_index: int - mode: int - initial_temperature_k: float - initial_gauge_pressure_pa: float - - @property - def initial_absolute_pressure_pa(self) -> float: - return self.initial_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA - - -@dataclass(frozen=True) -class TestMqlPnl0002Spec: - alias: str - source_component: str - source_port: str - target_component: str - target_port: str - diameter_mm: float - length_m: float - relative_roughness: float - polytropic_constant: float - heat_transfer_coefficient: float - external_temperature_k: float - gas_type_index: int - mode: int - initial_center_temperature_k: float - initial_center_gauge_pressure_pa: float - - @property - def initial_center_absolute_pressure_pa(self) -> float: - return self.initial_center_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA - - -@dataclass(frozen=True) -class TestMqlPnl0003Spec: - alias: str - source_component: str - source_port: str - target_component: str - target_port: str - diameter_mm: float - length_m: float - relative_roughness: float - polytropic_constant: float - heat_transfer_coefficient: float - external_temperature_k: float - gas_type_index: int - mode: int - initial_temperature_1_k: float - initial_gauge_pressure_1_pa: float - initial_temperature_2_k: float - initial_gauge_pressure_2_pa: float - - @property - def initial_absolute_pressure_1_pa(self) -> float: - return self.initial_gauge_pressure_1_pa + AMESIM_REFERENCE_PRESSURE_PA - - @property - def initial_absolute_pressure_2_pa(self) -> float: - return self.initial_gauge_pressure_2_pa + AMESIM_REFERENCE_PRESSURE_PA - - -@dataclass(frozen=True) -class TestMqlPnl00rSpec: - alias: str - source_component: str - source_port: str - target_component: str - target_port: str - diameter_mm: float - length_m: float - relative_roughness: float - gas_type_index: int - - -def load_test_mql_pnl0001_specs( - archive_path: str | Path, - *, - cir_member: str = "test_mql_.cir", -) -> tuple[TestMqlPnl0001Spec, ...]: - """Load resolved PNL0001 geometry and initial states from the AMESim source.""" - with tarfile.open(archive_path) as archive: - cir_file = archive.extractfile(cir_member) - if cir_file is None: - raise ValueError(f"Missing AMESim circuit member: {cir_member}") - cir_text = cir_file.read().decode("latin1") - - numeric_globals = { - name: value - for name, expression in GLOBAL_PARAMETERS.items() - if (value := resolve_numeric_expression(expression, {})) is not None - } - connections = { - str(connection["alias"]): connection - for connection in CONNECTION_SPECS - if connection["submodel"] == "PNL0001" - } - specs = [] - for block in re.findall(r".*?", cir_text, flags=re.DOTALL): - if _optional_text(block, "SUB_NAME") != "PNL0001": - continue - alias = _required_text(block, "ALIAS") - connection = connections.get(alias) - if connection is None: - raise ValueError(f"PNL0001 line {alias!r} is absent from CONNECTION_SPECS") - real_parameters = _parameter_expressions(block, "RPARAM") - integer_parameters = _parameter_expressions(block, "IPARAM") - state_values = _evar_values(block) - specs.append( - TestMqlPnl0001Spec( - alias=alias, - source_component=str(connection["source_component"]), - source_port=str(connection["source_port"]), - target_component=str(connection["target_component"]), - target_port=str(connection["target_port"]), - diameter_mm=_required_numeric( - alias, "diam", real_parameters, numeric_globals - ), - length_m=_required_numeric(alias, "le", real_parameters, numeric_globals), - relative_roughness=_required_numeric( - alias, "rr", real_parameters, numeric_globals - ), - polytropic_constant=_required_numeric( - alias, "k", real_parameters, numeric_globals - ), - heat_transfer_coefficient=_required_numeric( - alias, "kth", real_parameters, numeric_globals - ), - external_temperature_k=_required_numeric( - alias, "extemp", real_parameters, numeric_globals - ), - gas_type_index=int( - _required_numeric(alias, "gi", integer_parameters, numeric_globals) - ), - mode=int( - _required_numeric(alias, "mode", integer_parameters, numeric_globals) - ), - initial_temperature_k=_required_numeric( - alias, "t2", state_values, numeric_globals - ), - initial_gauge_pressure_pa=_required_numeric( - alias, "p2", state_values, numeric_globals - ), - ) - ) - if set(connections) != {spec.alias for spec in specs}: - missing = sorted(set(connections) - {spec.alias for spec in specs}) - raise ValueError(f"Missing PNL0001 parameter blocks: {missing}") - return tuple(specs) - - -def load_test_mql_pnl0002_specs( - archive_path: str | Path, - *, - cir_member: str = "test_mql_.cir", -) -> tuple[TestMqlPnl0002Spec, ...]: - """Load resolved PNL0002 geometry and center compliance initial state.""" - with tarfile.open(archive_path) as archive: - cir_file = archive.extractfile(cir_member) - if cir_file is None: - raise ValueError(f"Missing AMESim circuit member: {cir_member}") - cir_text = cir_file.read().decode("latin1") - - numeric_globals = { - name: value - for name, expression in GLOBAL_PARAMETERS.items() - if (value := resolve_numeric_expression(expression, {})) is not None - } - connections = { - str(connection["alias"]): connection - for connection in CONNECTION_SPECS - if connection["submodel"] == "PNL0002" - } - specs = [] - for block in re.findall(r".*?", cir_text, flags=re.DOTALL): - if _optional_text(block, "SUB_NAME") != "PNL0002": - continue - alias = _required_text(block, "ALIAS") - connection = connections.get(alias) - if connection is None: - raise ValueError(f"PNL0002 line {alias!r} is absent from CONNECTION_SPECS") - real_parameters = _parameter_expressions(block, "RPARAM") - integer_parameters = _parameter_expressions(block, "IPARAM") - state_values = _ivar_values(block) - specs.append( - TestMqlPnl0002Spec( - alias=alias, - source_component=str(connection["source_component"]), - source_port=str(connection["source_port"]), - target_component=str(connection["target_component"]), - target_port=str(connection["target_port"]), - diameter_mm=_required_numeric( - alias, "diam", real_parameters, numeric_globals - ), - length_m=_required_numeric(alias, "le", real_parameters, numeric_globals), - relative_roughness=_required_numeric( - alias, "rr", real_parameters, numeric_globals - ), - polytropic_constant=_required_numeric( - alias, "k", real_parameters, numeric_globals - ), - heat_transfer_coefficient=_required_numeric( - alias, "kth", real_parameters, numeric_globals - ), - external_temperature_k=_required_numeric( - alias, "extemp", real_parameters, numeric_globals - ), - gas_type_index=int( - _required_numeric(alias, "gi", integer_parameters, numeric_globals) - ), - mode=int( - _required_numeric(alias, "mode", integer_parameters, numeric_globals) - ), - initial_center_temperature_k=_required_numeric( - alias, "tctr", state_values, numeric_globals - ), - initial_center_gauge_pressure_pa=_required_numeric( - alias, "pctr", state_values, numeric_globals - ), - ) - ) - if set(connections) != {spec.alias for spec in specs}: - missing = sorted(set(connections) - {spec.alias for spec in specs}) - raise ValueError(f"Missing PNL0002 parameter blocks: {missing}") - return tuple(specs) - - -def load_test_mql_pnl0003_specs( - archive_path: str | Path, - *, - cir_member: str = "test_mql_.cir", -) -> tuple[TestMqlPnl0003Spec, ...]: - """Load resolved PNL0003 geometry and both compliance initial states.""" - with tarfile.open(archive_path) as archive: - cir_file = archive.extractfile(cir_member) - if cir_file is None: - raise ValueError(f"Missing AMESim circuit member: {cir_member}") - cir_text = cir_file.read().decode("latin1") - - numeric_globals = { - name: value - for name, expression in GLOBAL_PARAMETERS.items() - if (value := resolve_numeric_expression(expression, {})) is not None - } - connections = { - str(connection["alias"]): connection - for connection in CONNECTION_SPECS - if connection["submodel"] == "PNL0003" - } - specs = [] - for block in re.findall(r".*?", cir_text, flags=re.DOTALL): - if _optional_text(block, "SUB_NAME") != "PNL0003": - continue - alias = _required_text(block, "ALIAS") - connection = connections.get(alias) - if connection is None: - raise ValueError(f"PNL0003 line {alias!r} is absent from CONNECTION_SPECS") - real_parameters = _parameter_expressions(block, "RPARAM") - integer_parameters = _parameter_expressions(block, "IPARAM") - state_values = _evar_values(block) - specs.append( - TestMqlPnl0003Spec( - alias=alias, - source_component=str(connection["source_component"]), - source_port=str(connection["source_port"]), - target_component=str(connection["target_component"]), - target_port=str(connection["target_port"]), - diameter_mm=_required_numeric( - alias, "diam", real_parameters, numeric_globals - ), - length_m=_required_numeric(alias, "le", real_parameters, numeric_globals), - relative_roughness=_required_numeric( - alias, "rr", real_parameters, numeric_globals - ), - polytropic_constant=_required_numeric( - alias, "k", real_parameters, numeric_globals - ), - heat_transfer_coefficient=_required_numeric( - alias, "kth", real_parameters, numeric_globals - ), - external_temperature_k=_required_numeric( - alias, "extemp", real_parameters, numeric_globals - ), - gas_type_index=int( - _required_numeric(alias, "gi", integer_parameters, numeric_globals) - ), - mode=int( - _required_numeric(alias, "mode", integer_parameters, numeric_globals) - ), - initial_temperature_1_k=_required_numeric( - alias, "t1", state_values, numeric_globals - ), - initial_gauge_pressure_1_pa=_required_numeric( - alias, "p1", state_values, numeric_globals - ), - initial_temperature_2_k=_required_numeric( - alias, "t2", state_values, numeric_globals - ), - initial_gauge_pressure_2_pa=_required_numeric( - alias, "p2", state_values, numeric_globals - ), - ) - ) - if set(connections) != {spec.alias for spec in specs}: - missing = sorted(set(connections) - {spec.alias for spec in specs}) - raise ValueError(f"Missing PNL0003 parameter blocks: {missing}") - return tuple(specs) - - -def load_test_mql_pnl00r_specs( - archive_path: str | Path, - *, - cir_member: str = "test_mql_.cir", -) -> tuple[TestMqlPnl00rSpec, ...]: - """Load resolved PNL00R geometry from the AMESim source.""" - with tarfile.open(archive_path) as archive: - cir_file = archive.extractfile(cir_member) - if cir_file is None: - raise ValueError(f"Missing AMESim circuit member: {cir_member}") - cir_text = cir_file.read().decode("latin1") - - numeric_globals = { - name: value - for name, expression in GLOBAL_PARAMETERS.items() - if (value := resolve_numeric_expression(expression, {})) is not None - } - connections = { - str(connection["alias"]): connection - for connection in CONNECTION_SPECS - if connection["submodel"] == "PNL00R" - } - specs = [] - for block in re.findall(r".*?", cir_text, flags=re.DOTALL): - if _optional_text(block, "SUB_NAME") != "PNL00R": - continue - alias = _required_text(block, "ALIAS") - connection = connections.get(alias) - if connection is None: - raise ValueError(f"PNL00R line {alias!r} is absent from CONNECTION_SPECS") - real_parameters = _parameter_expressions(block, "RPARAM") - integer_parameters = _parameter_expressions(block, "IPARAM") - specs.append( - TestMqlPnl00rSpec( - alias=alias, - source_component=str(connection["source_component"]), - source_port=str(connection["source_port"]), - target_component=str(connection["target_component"]), - target_port=str(connection["target_port"]), - diameter_mm=_required_numeric( - alias, "diam", real_parameters, numeric_globals - ), - length_m=_required_numeric(alias, "le", real_parameters, numeric_globals), - relative_roughness=_required_numeric( - alias, "rr", real_parameters, numeric_globals - ), - gas_type_index=int( - _required_numeric(alias, "gi", integer_parameters, numeric_globals) - ), - ) - ) - if set(connections) != {spec.alias for spec in specs}: - missing = sorted(set(connections) - {spec.alias for spec in specs}) - raise ValueError(f"Missing PNL00R parameter blocks: {missing}") - return tuple(specs) - - -def _parameter_expressions(block: str, tag_name: str) -> dict[str, str]: - parameters = {} - for parameter_block in re.findall( - rf"<{tag_name}>.*?", - block, - flags=re.DOTALL, - ): - parameters[_required_text(parameter_block, "VARNAME")] = _required_text( - parameter_block, - "VALUE", - ) - return parameters - - -def _ivar_values(block: str) -> dict[str, str]: - values = {} - for variable_block in re.findall(r".*?", block, flags=re.DOTALL): - value = _optional_text(variable_block, "VALUE") - if value: - values[_required_text(variable_block, "VARNAME")] = value - return values - - -def _evar_values(block: str) -> dict[str, str]: - values = {} - for variable_block in re.findall(r".*?", block, flags=re.DOTALL): - value = _optional_text(variable_block, "VALUE") - if value: - values[_required_text(variable_block, "VARNAME")] = value - return values - - -def _required_numeric( - alias: str, - name: str, - expressions: dict[str, str], - variables: dict[str, float], -) -> float: - if name not in expressions: - raise ValueError(f"Missing {name!r} on line {alias!r}") - value = resolve_numeric_expression(expressions[name], variables) - if value is None: - raise ValueError( - f"Cannot resolve {name!r}={expressions[name]!r} on line {alias!r}" - ) - return value - - -def _required_text(block: str, tag_name: str) -> str: - value = _optional_text(block, tag_name) - if value is None: - raise ValueError(f"Missing AMESim circuit element: {tag_name}") - return value - - -def _optional_text(block: str, tag_name: str) -> str | None: - match = re.search(rf"<{tag_name}>(.*?)", block, flags=re.DOTALL) - return match.group(1).strip() if match is not None else None diff --git a/app/simulation/examples/test_mql/lines.py b/app/simulation/examples/test_mql/lines.py deleted file mode 100644 index 7715087..0000000 --- a/app/simulation/examples/test_mql/lines.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -import re -from collections import Counter -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_variables import ( - TestMqlVariableCatalog, - build_test_mql_variable_catalog, -) -from app.simulation.examples.test_mql.system import CONNECTION_SPECS - - -TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R") -_LINE_PATTERN_RE = re.compile(r"\(([^()]+)\)\s*$") - - -@dataclass(frozen=True) -class TestMqlLineConnection: - index: int - alias: str - submodel: str - pattern: str - source_component: str - source_port: str - target_component: str - target_port: str - label: str - data_paths: tuple[str, ...] - signal_names: tuple[str, ...] - - @property - def has_compliance(self) -> bool: - return "C" in self.pattern - - @property - def has_resistance(self) -> bool: - return "R" in self.pattern - - -@dataclass(frozen=True) -class TestMqlLineAssembly: - lines: tuple[TestMqlLineConnection, ...] - - @property - def line_count(self) -> int: - return len(self.lines) - - def by_alias(self, alias: str) -> TestMqlLineConnection: - for line in self.lines: - if line.alias == alias: - return line - raise KeyError(alias) - - def by_submodel(self, submodel: str) -> tuple[TestMqlLineConnection, ...]: - return tuple(line for line in self.lines if line.submodel == submodel) - - def counts_by_submodel(self) -> dict[str, int]: - return dict(Counter(line.submodel for line in self.lines)) - - def aliases(self) -> tuple[str, ...]: - return tuple(line.alias for line in self.lines) - - -def build_test_mql_line_assembly( - amesim_results: AmesimResults, - variable_catalog: TestMqlVariableCatalog | None = None, -) -> TestMqlLineAssembly: - variable_catalog = variable_catalog or build_test_mql_variable_catalog(amesim_results) - lines = [] - for spec in CONNECTION_SPECS: - submodel = str(spec["submodel"]) - if submodel not in TEST_MQL_PNEUMATIC_LINE_SUBMODELS: - continue - data_paths = variable_catalog.data_paths_for_owner(str(spec["alias"])) - signal_names = tuple(path.rsplit("@", 1)[0] for path in data_paths) - lines.append( - TestMqlLineConnection( - index=int(spec["index"]), - alias=str(spec["alias"]), - submodel=submodel, - pattern=_line_pattern(str(spec["label"]), submodel), - source_component=str(spec["source_component"]), - source_port=str(spec["source_port"]), - target_component=str(spec["target_component"]), - target_port=str(spec["target_port"]), - label=str(spec["label"]), - data_paths=data_paths, - signal_names=signal_names, - ) - ) - return TestMqlLineAssembly(lines=tuple(lines)) - - -def _line_pattern(label: str, submodel: str) -> str: - match = _LINE_PATTERN_RE.search(label) - if match is not None: - return match.group(1) - if submodel == "PNL00R": - return "R" - return submodel diff --git a/app/simulation/examples/test_mql/mechanical.py b/app/simulation/examples/test_mql/mechanical.py deleted file mode 100644 index a319561..0000000 --- a/app/simulation/examples/test_mql/mechanical.py +++ /dev/null @@ -1,544 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.examples.test_mql.primitives.mechanical import ( - AmesimElasticEndstop, - AmesimMassFrictionEndstops, - AmesimPistonGeometry, - circular_area, - mm_to_m, -) -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_variables import ( - TestMqlVariableCatalog, - build_test_mql_variable_catalog, -) -from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent - - -MM_TO_M = 1.0e-3 -N_PER_MM_TO_N_PER_M = 1.0e3 -N_PER_MM_PER_S_TO_N_PER_M_PER_S = 1.0e3 - - -@dataclass(frozen=True) -class TestMqlPistonSpec: - alias: str - piston_diameter_m: float - rod_diameter_m: float - zero_displacement_m: float - piston_area_m2: float - rod_area_m2: float - annulus_area_m2: float - data_paths: tuple[str, ...] - - def geometry(self) -> AmesimPistonGeometry: - return AmesimPistonGeometry( - piston_diameter_m=self.piston_diameter_m, - rod_diameter_m=self.rod_diameter_m, - zero_length_m=self.zero_displacement_m, - ) - - -@dataclass(frozen=True) -class TestMqlMassEndstopSpec: - alias: str - mass_kg: float - xmin_m: float - xmax_m: float - min_stiffness_n_per_m: float - max_stiffness_n_per_m: float - min_damping_n_per_m_per_s: float - max_damping_n_per_m_per_s: float - min_penetration_m: float - max_penetration_m: float - stiction_force_n: float - coulomb_friction_n: float - viscous_friction_n_per_m_per_s: float - windage_n_per_m2_per_s2: float - stick_velocity_threshold_m_s: float - reset_velocity_threshold_m_s: float - rest_coeff: float - stribeck_constant_m_s: float - use_friction: bool - stop_type: int - initial_velocity_m_s: float - initial_displacement_m: float - data_paths: tuple[str, ...] - - def endstop(self) -> AmesimMassFrictionEndstops: - return AmesimMassFrictionEndstops( - mass_kg=self.mass_kg, - lower_limit_m=self.xmin_m, - upper_limit_m=self.xmax_m, - lower_stiffness_n_per_m=self.min_stiffness_n_per_m, - upper_stiffness_n_per_m=self.max_stiffness_n_per_m, - lower_damping_n_per_m_per_s=self.min_damping_n_per_m_per_s, - upper_damping_n_per_m_per_s=self.max_damping_n_per_m_per_s, - viscous_friction_n_per_m_per_s=self.viscous_friction_n_per_m_per_s, - coulomb_friction_n=self.coulomb_friction_n, - stiction_force_n=self.stiction_force_n, - windage_n_per_m2_per_s2=self.windage_n_per_m2_per_s2, - ) - - -@dataclass(frozen=True) -class TestMqlElasticEndstopSpec: - alias: str - gap_m: float - contact_stiffness_n_per_m: float - contact_damping_n_per_m_per_s: float - spring_diameter_m: float - wire_diameter_m: float - data_paths: tuple[str, ...] - - def endstop(self) -> AmesimElasticEndstop: - return AmesimElasticEndstop( - contact_stiffness_n_per_m=self.contact_stiffness_n_per_m, - contact_damping_n_per_m_per_s=self.contact_damping_n_per_m_per_s, - gap0_m=self.gap_m, - ) - - -@dataclass(frozen=True) -class TestMqlMechanicalNodeSpec: - alias: str - port_count: int - sum_mode: int - data_paths: tuple[str, ...] - - -@dataclass(frozen=True) -class TestMqlPiecewiseLinearSignalSpec: - alias: str - t_start_s: float - starts: tuple[float, ...] - ends: tuple[float, ...] - durations_s: tuple[float, ...] - stage_count: int - is_cyclic: bool - data_paths: tuple[str, ...] - - def output_at(self, time_s: float) -> float: - if self.stage_count <= 0: - return 0.0 - elapsed = max(time_s - self.t_start_s, 0.0) - active_durations = self.durations_s[: self.stage_count] - total_duration = sum(active_durations) - if self.is_cyclic and total_duration > 0.0: - elapsed = elapsed % total_duration - - stage_start_time = 0.0 - for index, duration in enumerate(active_durations): - stage_end_time = stage_start_time + duration - if elapsed < stage_end_time or index == self.stage_count - 1: - if duration <= 0.0: - return self.ends[index] - fraction = (elapsed - stage_start_time) / duration - return self.starts[index] + fraction * (self.ends[index] - self.starts[index]) - stage_start_time = stage_end_time - return self.ends[self.stage_count - 1] - - -@dataclass(frozen=True) -class TestMqlForceConnectorSpec: - alias: str - signal_alias: str - target_mass_alias: str - data_paths: tuple[str, ...] - - def force_at( - self, - time_s: float, - signals: dict[str, TestMqlPiecewiseLinearSignalSpec], - ) -> float: - return signals[self.signal_alias].output_at(time_s) - - -@dataclass(frozen=True) -class TestMqlMechanicalAssembly: - pistons: dict[str, TestMqlPistonSpec] - masses: dict[str, TestMqlMassEndstopSpec] - elastic_endstops: dict[str, TestMqlElasticEndstopSpec] - mechanical_nodes: dict[str, TestMqlMechanicalNodeSpec] - piecewise_signals: dict[str, TestMqlPiecewiseLinearSignalSpec] - force_connectors: dict[str, TestMqlForceConnectorSpec] - zero_force_sources: tuple[str, ...] - - @property - def component_count(self) -> int: - return ( - len(self.pistons) - + len(self.masses) - + len(self.elastic_endstops) - + len(self.mechanical_nodes) - + len(self.piecewise_signals) - + len(self.force_connectors) - + len(self.zero_force_sources) - ) - - @property - def aliases(self) -> tuple[str, ...]: - return tuple( - [ - *self.pistons, - *self.masses, - *self.elastic_endstops, - *self.mechanical_nodes, - *self.piecewise_signals, - *self.force_connectors, - *self.zero_force_sources, - ] - ) - - -@dataclass(frozen=True) -class TestMqlMechanicalMassState: - alias: str - velocity_m_s: float - displacement_m: float - - def as_vector(self) -> list[float]: - return [self.velocity_m_s, self.displacement_m] - - -@dataclass(frozen=True) -class TestMqlMechanicalNodeKinematics: - alias: str - velocities_m_s: dict[int, float] - displacements_m: dict[int, float] - - -@dataclass(frozen=True) -class TestMqlPistonKinematics: - alias: str - port_2_velocity_m_s: float - port_2_displacement_m: float - port_3_velocity_m_s: float - port_3_displacement_m: float - - -@dataclass(frozen=True) -class TestMqlMechanicalMassSnapshot: - states: tuple[TestMqlMechanicalMassState, ...] - node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics] - piston_kinematics_by_alias: dict[str, TestMqlPistonKinematics] - - @property - def state_count(self) -> int: - return 2 * len(self.states) - - -class TestMqlMechanicalMassClosure: - def __init__(self, assembly: TestMqlMechanicalAssembly) -> None: - self.assembly = assembly - self.mass_aliases = tuple(assembly.masses) - - def initial_state_vector(self) -> list[float]: - state: list[float] = [] - for alias in self.mass_aliases: - spec = self.assembly.masses[alias] - state.extend([spec.initial_velocity_m_s, spec.initial_displacement_m]) - return state - - def snapshot(self, state_vector: list[float] | None = None) -> TestMqlMechanicalMassSnapshot: - values = self.initial_state_vector() if state_vector is None else list(state_vector) - if len(values) != 2 * len(self.mass_aliases): - raise ValueError("mechanical mass state vector requires two values per mass") - states = tuple( - TestMqlMechanicalMassState( - alias=alias, - velocity_m_s=values[2 * index], - displacement_m=values[2 * index + 1], - ) - for index, alias in enumerate(self.mass_aliases) - ) - node_kinematics = self._node_kinematics_by_alias(states) - return TestMqlMechanicalMassSnapshot( - states=states, - node_kinematics_by_alias=node_kinematics, - piston_kinematics_by_alias=self._piston_kinematics_by_alias( - states, - node_kinematics, - ), - ) - - def _node_kinematics_by_alias( - self, - states: tuple[TestMqlMechanicalMassState, ...], - ) -> dict[str, TestMqlMechanicalNodeKinematics]: - state_by_alias = {state.alias: state for state in states} - front = state_by_alias["mass_friction_endstops_18"] - rear = state_by_alias["mass_friction_endstops_19"] - return { - "dynamic_mechanical_node_alternative_2": TestMqlMechanicalNodeKinematics( - alias="dynamic_mechanical_node_alternative_2", - velocities_m_s={port: -front.velocity_m_s for port in range(1, 9)}, - displacements_m={port: -front.displacement_m for port in range(1, 9)}, - ), - "dynamic_mechanical_node_alternative_3": TestMqlMechanicalNodeKinematics( - alias="dynamic_mechanical_node_alternative_3", - velocities_m_s={port: rear.velocity_m_s for port in range(1, 9)}, - displacements_m={port: rear.displacement_m for port in range(1, 9)}, - ), - } - - def _piston_kinematics_by_alias( - self, - states: tuple[TestMqlMechanicalMassState, ...], - node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics], - ) -> dict[str, TestMqlPistonKinematics]: - state_by_alias = {state.alias: state for state in states} - rear_node = node_kinematics_by_alias["dynamic_mechanical_node_alternative_3"] - piston_bindings = ( - ("pn_brp2_8", "mass_friction_endstops_10", 8), - ("pn_brp2_9", "mass_friction_endstops_11", 7), - ("pn_brp2_10", "mass_friction_endstops_12", 6), - ("pn_brp2_11", "mass_friction_endstops_13", 5), - ("pn_brp2_12", "mass_friction_endstops_14", 4), - ("pn_brp2_13", "mass_friction_endstops_15", 3), - ("pn_brp2_14", "mass_friction_endstops_16", 2), - ("pn_brp2_15", "mass_friction_endstops_17", 1), - ) - return { - piston_alias: TestMqlPistonKinematics( - alias=piston_alias, - port_2_velocity_m_s=state_by_alias[mass_alias].velocity_m_s, - port_2_displacement_m=state_by_alias[mass_alias].displacement_m, - port_3_velocity_m_s=rear_node.velocities_m_s[rear_node_port], - port_3_displacement_m=rear_node.displacements_m[rear_node_port], - ) - for piston_alias, mass_alias, rear_node_port in piston_bindings - } - - def rhs( - self, - state_vector: list[float], - *, - force_by_mass_alias: dict[str, float] | None = None, - constrained_mass_aliases: set[str] | None = None, - ) -> list[float]: - snapshot = self.snapshot(state_vector) - force_by_mass_alias = force_by_mass_alias or {} - constrained_mass_aliases = constrained_mass_aliases or set() - derivatives: list[float] = [] - for state in snapshot.states: - spec = self.assembly.masses[state.alias] - mass = spec.endstop() - applied_force = force_by_mass_alias.get(state.alias, 0.0) - acceleration, velocity = mass.derivatives( - velocity_m_s=state.velocity_m_s, - displacement_m=state.displacement_m, - port_1_force_n=applied_force, - ) - if state.alias in constrained_mass_aliases and _limit_constraint_holds( - spec, - state, - applied_force, - ): - acceleration = 0.0 - velocity = 0.0 - derivatives.extend([acceleration, velocity]) - return derivatives - - -def build_test_mql_mechanical_assembly( - config: TestMqlConfig | None = None, - amesim_results: AmesimResults | None = None, - variable_catalog: TestMqlVariableCatalog | None = None, -) -> TestMqlMechanicalAssembly: - config = config or TestMqlConfig.from_amesim_specs() - if variable_catalog is None and amesim_results is not None: - variable_catalog = build_test_mql_variable_catalog(amesim_results) - - pistons = { - component.alias: _build_piston(component, variable_catalog) - for component in config.components_by_submodel("PNRP17") - } - masses = { - component.alias: _build_mass(component, variable_catalog, amesim_results) - for component in config.components_by_submodel("MECMAS21") - } - elastic_endstops = { - component.alias: _build_elastic_endstop(component, variable_catalog) - for component in config.components_by_submodel("LSTP00A") - } - mechanical_nodes = { - component.alias: _build_mechanical_node(component, variable_catalog) - for component in config.components_by_submodel("LMECHN1") - } - piecewise_signals = { - component.alias: _build_piecewise_signal(component, variable_catalog) - for component in config.components_by_submodel("UD00") - } - force_connectors = { - component.alias: _build_force_connector(component, variable_catalog) - for component in config.components_by_submodel("FORC") - } - zero_force_sources = tuple(component.alias for component in config.components_by_submodel("F000")) - return TestMqlMechanicalAssembly( - pistons=pistons, - masses=masses, - elastic_endstops=elastic_endstops, - mechanical_nodes=mechanical_nodes, - piecewise_signals=piecewise_signals, - force_connectors=force_connectors, - zero_force_sources=zero_force_sources, - ) - - -def _build_piston( - component: TestMqlResolvedComponent, - variable_catalog: TestMqlVariableCatalog | None, -) -> TestMqlPistonSpec: - geometry = AmesimPistonGeometry( - piston_diameter_m=mm_to_m(component.parameter_value("dp")), - rod_diameter_m=mm_to_m(component.parameter_value("dr")), - zero_length_m=mm_to_m(component.parameter_value("x0")), - ) - return TestMqlPistonSpec( - alias=component.alias, - piston_diameter_m=geometry.piston_diameter_m, - rod_diameter_m=geometry.rod_diameter_m, - zero_displacement_m=geometry.zero_length_m, - piston_area_m2=geometry.piston_area_m2, - rod_area_m2=geometry.rod_area_m2, - annulus_area_m2=geometry.annulus_area_m2, - data_paths=_data_paths(variable_catalog, component.alias), - ) - - -def _build_mass( - component: TestMqlResolvedComponent, - variable_catalog: TestMqlVariableCatalog | None, - amesim_results: AmesimResults | None, -) -> TestMqlMassEndstopSpec: - return TestMqlMassEndstopSpec( - alias=component.alias, - mass_kg=component.parameter_value("mass"), - xmin_m=component.parameter_value("xmin"), - xmax_m=component.parameter_value("xmax"), - min_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmin")), - max_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmax")), - min_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmin")), - max_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmax")), - min_penetration_m=mm_to_m(component.parameter_value("Pdmin")), - max_penetration_m=mm_to_m(component.parameter_value("Pdmax")), - stiction_force_n=component.parameter_value("fstick"), - coulomb_friction_n=component.parameter_value("fcoul"), - viscous_friction_n_per_m_per_s=component.parameter_value("rvisc"), - windage_n_per_m2_per_s2=component.parameter_value("wind"), - stick_velocity_threshold_m_s=component.parameter_value("dvel"), - reset_velocity_threshold_m_s=component.parameter_value("restdvel"), - rest_coeff=component.parameter_value("restcoeff"), - stribeck_constant_m_s=component.parameter_value("astrib"), - use_friction=int(component.parameter_value("useFriction")) == 2, - stop_type=int(component.parameter_value("stoptype")), - initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"), - initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"), - data_paths=_data_paths(variable_catalog, component.alias), - ) - - -def _build_elastic_endstop( - component: TestMqlResolvedComponent, - variable_catalog: TestMqlVariableCatalog | None, -) -> TestMqlElasticEndstopSpec: - return TestMqlElasticEndstopSpec( - alias=component.alias, - gap_m=mm_to_m(component.parameter_value("gap0")), - contact_stiffness_n_per_m=component.parameter_value("kcont"), - contact_damping_n_per_m_per_s=component.parameter_value("rcont"), - spring_diameter_m=mm_to_m(component.parameter_value("sdiam")), - wire_diameter_m=mm_to_m(component.parameter_value("wdiam")), - data_paths=_data_paths(variable_catalog, component.alias), - ) - - -def _build_mechanical_node( - component: TestMqlResolvedComponent, - variable_catalog: TestMqlVariableCatalog | None, -) -> TestMqlMechanicalNodeSpec: - return TestMqlMechanicalNodeSpec( - alias=component.alias, - port_count=int(component.parameter_value("v1")), - sum_mode=int(component.parameter_value("sum")), - data_paths=_data_paths(variable_catalog, component.alias), - ) - - -def _limit_constraint_holds( - spec: TestMqlMassEndstopSpec, - state: TestMqlMechanicalMassState, - applied_force_n: float, -) -> bool: - if abs(state.velocity_m_s) > spec.stick_velocity_threshold_m_s: - return False - at_lower_limit = state.displacement_m <= spec.xmin_m + spec.min_penetration_m - at_upper_limit = state.displacement_m >= spec.xmax_m - spec.max_penetration_m - return (at_lower_limit and applied_force_n <= 0.0) or ( - at_upper_limit and applied_force_n >= 0.0 - ) - - -def _build_piecewise_signal( - component: TestMqlResolvedComponent, - variable_catalog: TestMqlVariableCatalog | None, -) -> TestMqlPiecewiseLinearSignalSpec: - starts = tuple(component.parameter_value(f"start{index}") for index in range(1, 9)) - ends = tuple(component.parameter_value(f"end{index}") for index in range(1, 9)) - durations = tuple(component.parameter_value(f"t{index}") for index in range(1, 9)) - return TestMqlPiecewiseLinearSignalSpec( - alias=component.alias, - t_start_s=component.parameter_value("tstart"), - starts=starts, - ends=ends, - durations_s=durations, - stage_count=int(component.parameter_value("nstages")), - is_cyclic=bool(int(component.parameter_value("iscyclic"))), - data_paths=_data_paths(variable_catalog, component.alias), - ) - - -def _build_force_connector( - component: TestMqlResolvedComponent, - variable_catalog: TestMqlVariableCatalog | None, -) -> TestMqlForceConnectorSpec: - signal_alias_by_force_connector = { - "forcecon_1": "piecewiselinear", - "forcecon_2": "piecewiselinear_1", - } - target_mass_by_force_connector = { - "forcecon_1": "mass_friction_endstops_19", - "forcecon_2": "mass_friction_endstops_18", - } - return TestMqlForceConnectorSpec( - alias=component.alias, - signal_alias=signal_alias_by_force_connector[component.alias], - target_mass_alias=target_mass_by_force_connector[component.alias], - data_paths=_data_paths(variable_catalog, component.alias), - ) - - -def n_per_mm_to_n_per_m(value: float) -> float: - return value * N_PER_MM_TO_N_PER_M - - -def n_per_mm_per_s_to_n_per_m_per_s(value: float) -> float: - return value * N_PER_MM_PER_S_TO_N_PER_M_PER_S - - -def _initial_value(amesim_results: AmesimResults | None, data_path: str) -> float: - if amesim_results is None: - return 0.0 - return float(amesim_results.series(data_path)[0]) - - -def _data_paths( - variable_catalog: TestMqlVariableCatalog | None, - alias: str, -) -> tuple[str, ...]: - if variable_catalog is None: - return () - return variable_catalog.data_paths_for_owner(alias) diff --git a/app/simulation/examples/test_mql/nodes.py b/app/simulation/examples/test_mql/nodes.py deleted file mode 100644 index 001a782..0000000 --- a/app/simulation/examples/test_mql/nodes.py +++ /dev/null @@ -1,215 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.examples.test_mql.system import COMPONENT_SPECS - - -@dataclass(frozen=True) -class TestMqlPneumaticNode3Balance: - temperature_k: float - pressure_pa: float - port_1_enthalpy_flow_w: float - port_1_mass_flow_g_s: float - port_1_volume_derivative_l_min: float - port_1_volume_cm3: float - port_2_enthalpy_flow_w: float - port_2_mass_flow_g_s: float - port_2_volume_derivative_l_min: float - port_2_volume_cm3: float - port_3_enthalpy_flow_w: float - port_3_mass_flow_g_s: float - port_3_volume_derivative_l_min: float - port_3_volume_cm3: float - - -@dataclass(frozen=True) -class TestMqlPneumaticNode3: - """Exact algebraic contract of AMESim ``PN3NODE2``. - - Pressure and temperature are fixed by port 2 and duplicated to ports 1 and - 3. Flow and volume signals at port 2 are the sums of ports 1 and 3, matching - the ``EXPRESS2`` equations stored in ``test_mql_.cir``. - """ - - alias: str - - def balance( - self, - *, - port_2_temperature_k: float, - port_2_pressure_pa: float, - port_1_enthalpy_flow_w: float, - port_1_mass_flow_g_s: float, - port_3_enthalpy_flow_w: float, - port_3_mass_flow_g_s: float, - port_1_volume_derivative_l_min: float = 0.0, - port_1_volume_cm3: float = 0.0, - port_3_volume_derivative_l_min: float = 0.0, - port_3_volume_cm3: float = 0.0, - ) -> TestMqlPneumaticNode3Balance: - if port_2_temperature_k <= 0.0: - raise ValueError("port_2_temperature_k must be positive") - if port_2_pressure_pa <= 0.0: - raise ValueError("port_2_pressure_pa must be positive") - return TestMqlPneumaticNode3Balance( - temperature_k=port_2_temperature_k, - pressure_pa=port_2_pressure_pa, - port_1_enthalpy_flow_w=port_1_enthalpy_flow_w, - port_1_mass_flow_g_s=port_1_mass_flow_g_s, - port_1_volume_derivative_l_min=port_1_volume_derivative_l_min, - port_1_volume_cm3=port_1_volume_cm3, - port_2_enthalpy_flow_w=( - port_1_enthalpy_flow_w + port_3_enthalpy_flow_w - ), - port_2_mass_flow_g_s=port_1_mass_flow_g_s + port_3_mass_flow_g_s, - port_2_volume_derivative_l_min=( - port_1_volume_derivative_l_min + port_3_volume_derivative_l_min - ), - port_2_volume_cm3=port_1_volume_cm3 + port_3_volume_cm3, - port_3_enthalpy_flow_w=port_3_enthalpy_flow_w, - port_3_mass_flow_g_s=port_3_mass_flow_g_s, - port_3_volume_derivative_l_min=port_3_volume_derivative_l_min, - port_3_volume_cm3=port_3_volume_cm3, - ) - - -@dataclass(frozen=True) -class TestMqlPneumaticNode4Balance: - temperature_k: float - pressure_pa: float - port_1_enthalpy_flow_w: float - port_1_mass_flow_g_s: float - port_1_volume_derivative_l_min: float - port_1_volume_cm3: float - port_2_enthalpy_flow_w: float - port_2_mass_flow_g_s: float - port_2_volume_derivative_l_min: float - port_2_volume_cm3: float - port_3_enthalpy_flow_w: float - port_3_mass_flow_g_s: float - port_3_volume_derivative_l_min: float - port_3_volume_cm3: float - port_4_enthalpy_flow_w: float - port_4_mass_flow_g_s: float - port_4_volume_derivative_l_min: float - port_4_volume_cm3: float - - -@dataclass(frozen=True) -class TestMqlPneumaticNode4: - """Exact algebraic contract of AMESim ``P4NODE2``. - - Pressure and temperature are fixed by port 2 and duplicated to ports 1, 3, - and 4. Flow and volume signals at port 2 are the sums of ports 1, 3, and - 4, matching the saved AMESim variables for ``pnnode4_*`` instances. - """ - - alias: str - - def balance( - self, - *, - port_2_temperature_k: float, - port_2_pressure_pa: float, - port_1_enthalpy_flow_w: float, - port_1_mass_flow_g_s: float, - port_3_enthalpy_flow_w: float, - port_3_mass_flow_g_s: float, - port_4_enthalpy_flow_w: float, - port_4_mass_flow_g_s: float, - port_1_volume_derivative_l_min: float = 0.0, - port_1_volume_cm3: float = 0.0, - port_3_volume_derivative_l_min: float = 0.0, - port_3_volume_cm3: float = 0.0, - port_4_volume_derivative_l_min: float = 0.0, - port_4_volume_cm3: float = 0.0, - ) -> TestMqlPneumaticNode4Balance: - if port_2_temperature_k <= 0.0: - raise ValueError("port_2_temperature_k must be positive") - if port_2_pressure_pa <= 0.0: - raise ValueError("port_2_pressure_pa must be positive") - return TestMqlPneumaticNode4Balance( - temperature_k=port_2_temperature_k, - pressure_pa=port_2_pressure_pa, - port_1_enthalpy_flow_w=port_1_enthalpy_flow_w, - port_1_mass_flow_g_s=port_1_mass_flow_g_s, - port_1_volume_derivative_l_min=port_1_volume_derivative_l_min, - port_1_volume_cm3=port_1_volume_cm3, - port_2_enthalpy_flow_w=( - port_1_enthalpy_flow_w - + port_3_enthalpy_flow_w - + port_4_enthalpy_flow_w - ), - port_2_mass_flow_g_s=( - port_1_mass_flow_g_s - + port_3_mass_flow_g_s - + port_4_mass_flow_g_s - ), - port_2_volume_derivative_l_min=( - port_1_volume_derivative_l_min - + port_3_volume_derivative_l_min - + port_4_volume_derivative_l_min - ), - port_2_volume_cm3=( - port_1_volume_cm3 + port_3_volume_cm3 + port_4_volume_cm3 - ), - port_3_enthalpy_flow_w=port_3_enthalpy_flow_w, - port_3_mass_flow_g_s=port_3_mass_flow_g_s, - port_3_volume_derivative_l_min=port_3_volume_derivative_l_min, - port_3_volume_cm3=port_3_volume_cm3, - port_4_enthalpy_flow_w=port_4_enthalpy_flow_w, - port_4_mass_flow_g_s=port_4_mass_flow_g_s, - port_4_volume_derivative_l_min=port_4_volume_derivative_l_min, - port_4_volume_cm3=port_4_volume_cm3, - ) - - -@dataclass(frozen=True) -class TestMqlP4NodePortConnection: - line_alias: str - local_node_alias: str - local_port: str - remote_node_alias: str - remote_port: str - - -@dataclass(frozen=True) -class TestMqlP4NodePrimaryConnection: - line_alias: str - node_alias: str - node_port: str - chamber_alias: str - chamber_port: str - - -@dataclass(frozen=True) -class TestMqlP4NodeOrificeConnection: - orifice_alias: str - node_alias: str - node_port: str - direct_line_alias: str - - -@dataclass(frozen=True) -class TestMqlP4NodeNeighborhood: - node_alias: str - primary: TestMqlP4NodePrimaryConnection - port_1: TestMqlP4NodePortConnection - port_3: TestMqlP4NodePortConnection - port_4: TestMqlP4NodeOrificeConnection - - -def build_test_mql_node3_assembly() -> dict[str, TestMqlPneumaticNode3]: - return { - str(spec["alias"]): TestMqlPneumaticNode3(alias=str(spec["alias"])) - for spec in COMPONENT_SPECS - if spec["submodel"] == "PN3NODE2" - } - -def build_test_mql_node4_assembly() -> dict[str, TestMqlPneumaticNode4]: - return { - str(spec["alias"]): TestMqlPneumaticNode4(alias=str(spec["alias"])) - for spec in COMPONENT_SPECS - if spec["submodel"] == "P4NODE2" - } diff --git a/app/simulation/examples/test_mql/pneumatic.py b/app/simulation/examples/test_mql/pneumatic.py deleted file mode 100644 index edebf7d..0000000 --- a/app/simulation/examples/test_mql/pneumatic.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.examples.test_mql.primitives.pneumatic import ( - HELIUM_PNEUMATIC_GAS, - AmesimPneumaticGas, - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - AmesimVariablePneumaticVolume, -) -from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent - - -AMESIM_REFERENCE_PRESSURE_PA = 101_300.0 -BAR_TO_PA = 1.0e5 -DEFAULT_TEST_MQL_TEMPERATURE_K = 293.15 -DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR = 1.0 -# Matched to PNVO001 event-window mass flow near the 0.04 s opening event. -TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER = 0.99805 - - -@dataclass(frozen=True) -class TestMqlStepSignalSpec: - alias: str - initial_output: float - final_output: float - step_time_s: float - transition_duration_s: float - transition_type: int - - def output_at(self, time_s: float) -> float: - if self.transition_type != 1: - raise ValueError( - f"unsupported STEP0 transition type {self.transition_type} on {self.alias}" - ) - return self.initial_output if time_s < self.step_time_s else self.final_output - - -@dataclass(frozen=True) -class TestMqlVariableOrificeControl: - orifice_alias: str - step: TestMqlStepSignalSpec - - def opening_at(self, time_s: float) -> float: - return self.step.output_at(time_s) - - -@dataclass(frozen=True) -class TestMqlPneumaticAssembly: - fixed_chambers: dict[str, AmesimPneumaticVolume] - variable_chambers: dict[str, AmesimVariablePneumaticVolume] - fixed_orifices: dict[str, AmesimPneumaticOrifice] - variable_orifices: dict[str, AmesimPneumaticOrifice] - variable_orifice_controls: dict[str, TestMqlVariableOrificeControl] - fixed_initial_absolute_pressure_pa: float - variable_initial_absolute_pressure_pa: float - - @property - def initial_pressure_pa(self) -> float: - return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa) - - @property - def fixed_initial_gauge_pressure_pa(self) -> float: - return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa) - - @property - def variable_initial_gauge_pressure_pa(self) -> float: - return pressure_to_amesim_gauge_pa(self.variable_initial_absolute_pressure_pa) - - @property - def chamber_count(self) -> int: - return len(self.fixed_chambers) + len(self.variable_chambers) - - @property - def orifice_count(self) -> int: - return len(self.fixed_orifices) + len(self.variable_orifices) - - @property - def component_count(self) -> int: - return self.chamber_count + self.orifice_count - - @property - def variable_orifice_control_count(self) -> int: - return len(self.variable_orifice_controls) - - def set_variable_orifice_openings(self, time_s: float) -> None: - for alias, control in self.variable_orifice_controls.items(): - self.variable_orifices[alias].opening = control.opening_at(time_s) - - @property - def aliases(self) -> tuple[str, ...]: - return tuple( - [ - *self.fixed_chambers, - *self.variable_chambers, - *self.fixed_orifices, - *self.variable_orifices, - ] - ) - - -def build_test_mql_pneumatic_assembly( - config: TestMqlConfig | None = None, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, -) -> TestMqlPneumaticAssembly: - config = config or TestMqlConfig.from_amesim_specs() - fixed_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter( - config.global_parameters["P0"] - ) - variable_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter( - DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR - ) - fixed_chambers = { - component.alias: _build_chamber( - component, - volume_parameter="cvol", - gas=gas, - initial_pressure_pa=fixed_initial_absolute_pressure_pa, - ) - for component in config.components_by_submodel("PNCH023") - } - variable_chambers = { - component.alias: _build_chamber( - component, - volume_parameter="cvol0", - gas=gas, - initial_pressure_pa=variable_initial_absolute_pressure_pa, - ) - for component in config.components_by_submodel("PNCH012") - } - fixed_orifices = { - component.alias: _build_orifice( - component, - area_parameter="area", - gas=gas, - opening=1.0, - ) - for component in config.components_by_submodel("PNOR001") - } - variable_orifice_controls = _build_variable_orifice_controls(config) - variable_orifices = { - component.alias: _build_orifice( - component, - area_parameter="area0", - gas=gas, - opening=variable_orifice_controls[component.alias].opening_at(0.0), - ) - for component in config.components_by_submodel("PNVO001") - } - return TestMqlPneumaticAssembly( - fixed_chambers=fixed_chambers, - variable_chambers=variable_chambers, - fixed_orifices=fixed_orifices, - variable_orifices=variable_orifices, - variable_orifice_controls=variable_orifice_controls, - fixed_initial_absolute_pressure_pa=fixed_initial_absolute_pressure_pa, - variable_initial_absolute_pressure_pa=variable_initial_absolute_pressure_pa, - ) - - -def _build_variable_orifice_controls( - config: TestMqlConfig, -) -> dict[str, TestMqlVariableOrificeControl]: - from app.simulation.examples.test_mql.system import CONNECTION_SPECS - - components_by_alias = {component.alias: component for component in config.components} - variable_orifice_aliases = { - component.alias for component in config.components_by_submodel("PNVO001") - } - controls: dict[str, TestMqlVariableOrificeControl] = {} - for connection in CONNECTION_SPECS: - if connection["submodel"] != "DIRECT": - continue - source_alias = str(connection["source_component"]) - target_alias = str(connection["target_component"]) - if target_alias in variable_orifice_aliases: - orifice_alias = target_alias - step_alias = source_alias - elif source_alias in variable_orifice_aliases: - orifice_alias = source_alias - step_alias = target_alias - else: - continue - step_component = components_by_alias.get(step_alias) - if step_component is None or step_component.submodel != "STEP0": - continue - controls[orifice_alias] = TestMqlVariableOrificeControl( - orifice_alias=orifice_alias, - step=TestMqlStepSignalSpec( - alias=step_alias, - initial_output=step_component.parameter_value("out0"), - final_output=step_component.parameter_value("out1"), - step_time_s=step_component.parameter_value("t0"), - transition_duration_s=step_component.parameter_value("td"), - transition_type=int(step_component.parameter_value("transitionType")), - ), - ) - missing = variable_orifice_aliases - controls.keys() - if missing: - raise ValueError( - "missing STEP0 controls for PNVO001 components: " - + ", ".join(sorted(missing)) - ) - return controls - - -def absolute_pressure_from_amesim_bar_parameter(pressure_bar: float) -> float: - return pressure_bar * BAR_TO_PA - - -def pressure_to_amesim_gauge_pa(absolute_pressure_pa: float) -> float: - return absolute_pressure_pa - AMESIM_REFERENCE_PRESSURE_PA - - -def pressure_from_amesim_bar_parameter(pressure_bar: float) -> float: - return pressure_to_amesim_gauge_pa(absolute_pressure_from_amesim_bar_parameter(pressure_bar)) - - -def _build_chamber( - component: TestMqlResolvedComponent, - *, - volume_parameter: str, - gas: AmesimPneumaticGas, - initial_pressure_pa: float, -) -> AmesimPneumaticVolume: - if volume_parameter == "cvol0": - return AmesimVariablePneumaticVolume.from_liters( - name=component.alias, - dead_volume_liters=component.parameter_value(volume_parameter), - gas=gas, - p0=initial_pressure_pa, - T0=_component_temperature(component), - heat_transfer_coefficient=component.parameter_value("kth"), - heat_transfer_area=component.parameter_value("sth"), - external_temperature_k=_component_temperature(component), - ) - return AmesimPneumaticVolume.from_liters( - name=component.alias, - volume_liters=component.parameter_value(volume_parameter), - gas=gas, - p0=initial_pressure_pa, - T0=_component_temperature(component), - heat_transfer_coefficient=component.parameter_value("kth"), - heat_transfer_area=component.parameter_value("sth"), - external_temperature_k=_component_temperature(component), - ) - - -def _build_orifice( - component: TestMqlResolvedComponent, - *, - area_parameter: str, - gas: AmesimPneumaticGas, - opening: float, -) -> AmesimPneumaticOrifice: - flow_coefficient = component.parameter_value("cq") - if component.submodel == "PNVO001": - flow_coefficient *= TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER - return AmesimPneumaticOrifice.from_mm2( - name=component.alias, - area_mm2=component.parameter_value(area_parameter), - flow_coefficient=flow_coefficient, - gas=gas, - opening=opening, - ) - - -def _component_temperature(component: TestMqlResolvedComponent) -> float: - parameter = component.parameters.get("extemp") - if parameter is None or parameter.value is None: - return DEFAULT_TEST_MQL_TEMPERATURE_K - return parameter.value diff --git a/app/simulation/examples/test_mql/pneumatic_lines.py b/app/simulation/examples/test_mql/pneumatic_lines.py deleted file mode 100644 index 0eb9414..0000000 --- a/app/simulation/examples/test_mql/pneumatic_lines.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -from app.simulation.examples.test_mql.primitives.pneumatic import ( - HELIUM_PNEUMATIC_GAS, - AmesimPneumaticGas, -) -from app.simulation.examples.test_mql.primitives.pneumatic_lines import ( - AmesimPnl0001Pipe, - AmesimPnl0002Pipe, - AmesimPnl0003Pipe, - AmesimPnl00rPipe, -) -from app.simulation.examples.test_mql.line_parameters import ( - TestMqlPnl0001Spec, - TestMqlPnl0002Spec, - TestMqlPnl0003Spec, - TestMqlPnl00rSpec, - load_test_mql_pnl0001_specs, - load_test_mql_pnl0002_specs, - load_test_mql_pnl0003_specs, - load_test_mql_pnl00r_specs, -) - - -TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE = 5.5636e-6 - - -@dataclass(frozen=True) -class TestMqlPnl0001Assembly: - specs: tuple[TestMqlPnl0001Spec, ...] - lines: dict[str, AmesimPnl0001Pipe] - - def spec(self, alias: str) -> TestMqlPnl0001Spec: - for spec in self.specs: - if spec.alias == alias: - return spec - raise KeyError(alias) - - -@dataclass(frozen=True) -class TestMqlPnl0002Assembly: - specs: tuple[TestMqlPnl0002Spec, ...] - lines: dict[str, AmesimPnl0002Pipe] - - def spec(self, alias: str) -> TestMqlPnl0002Spec: - for spec in self.specs: - if spec.alias == alias: - return spec - raise KeyError(alias) - - -@dataclass(frozen=True) -class TestMqlPnl0003Assembly: - specs: tuple[TestMqlPnl0003Spec, ...] - lines: dict[str, AmesimPnl0003Pipe] - - def spec(self, alias: str) -> TestMqlPnl0003Spec: - for spec in self.specs: - if spec.alias == alias: - return spec - raise KeyError(alias) - - -@dataclass(frozen=True) -class TestMqlPnl00rAssembly: - specs: tuple[TestMqlPnl00rSpec, ...] - lines: dict[str, AmesimPnl00rPipe] - - def spec(self, alias: str) -> TestMqlPnl00rSpec: - for spec in self.specs: - if spec.alias == alias: - return spec - raise KeyError(alias) - - -def build_test_mql_pnl0001_assembly( - archive_path: str | Path, - *, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, -) -> TestMqlPnl0001Assembly: - specs = load_test_mql_pnl0001_specs(archive_path) - lines = { - spec.alias: AmesimPnl0001Pipe( - name=spec.alias, - diameter_mm=spec.diameter_mm, - length_m=spec.length_m, - relative_roughness=spec.relative_roughness, - polytropic_constant=spec.polytropic_constant, - heat_transfer_coefficient=spec.heat_transfer_coefficient, - external_temperature_k=spec.external_temperature_k, - calibrated_linear_conductance=( - _test_mql_pnl0001_calibrated_linear_conductance(spec) - ), - gas=gas, - p0=spec.initial_absolute_pressure_pa, - T0=spec.initial_temperature_k, - ) - for spec in specs - } - return TestMqlPnl0001Assembly(specs=specs, lines=lines) - - -def _test_mql_pnl0001_calibrated_linear_conductance( - spec: TestMqlPnl0001Spec, -) -> float | None: - if spec.target_component.startswith("pn_c1_") and _matches_geometry( - spec, diameter_mm=20.0, length_m=1.0 - ): - return TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE - return None - - -def _matches_geometry( - spec: TestMqlPnl0001Spec, - *, - diameter_mm: float, - length_m: float, -) -> bool: - return ( - abs(spec.diameter_mm - diameter_mm) < 1.0e-12 - and abs(spec.length_m - length_m) < 1.0e-12 - ) - - -def build_test_mql_pnl0002_assembly( - archive_path: str | Path, - *, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, -) -> TestMqlPnl0002Assembly: - specs = load_test_mql_pnl0002_specs(archive_path) - lines = { - spec.alias: AmesimPnl0002Pipe( - name=spec.alias, - diameter_mm=spec.diameter_mm, - length_m=spec.length_m, - relative_roughness=spec.relative_roughness, - polytropic_constant=spec.polytropic_constant, - heat_transfer_coefficient=spec.heat_transfer_coefficient, - external_temperature_k=spec.external_temperature_k, - gas=gas, - pctr_0=spec.initial_center_absolute_pressure_pa, - Tctr_0=spec.initial_center_temperature_k, - ) - for spec in specs - } - return TestMqlPnl0002Assembly(specs=specs, lines=lines) - - -def build_test_mql_pnl0003_assembly( - archive_path: str | Path, - *, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, -) -> TestMqlPnl0003Assembly: - specs = load_test_mql_pnl0003_specs(archive_path) - lines = { - spec.alias: AmesimPnl0003Pipe( - name=spec.alias, - diameter_mm=spec.diameter_mm, - length_m=spec.length_m, - relative_roughness=spec.relative_roughness, - polytropic_constant=spec.polytropic_constant, - heat_transfer_coefficient=spec.heat_transfer_coefficient, - external_temperature_k=spec.external_temperature_k, - gas=gas, - p1_0=spec.initial_absolute_pressure_1_pa, - T1_0=spec.initial_temperature_1_k, - p2_0=spec.initial_absolute_pressure_2_pa, - T2_0=spec.initial_temperature_2_k, - ) - for spec in specs - } - return TestMqlPnl0003Assembly(specs=specs, lines=lines) - - -def build_test_mql_pnl00r_assembly( - archive_path: str | Path, - *, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, -) -> TestMqlPnl00rAssembly: - specs = load_test_mql_pnl00r_specs(archive_path) - lines = { - spec.alias: AmesimPnl00rPipe( - name=spec.alias, - diameter_mm=spec.diameter_mm, - length_m=spec.length_m, - relative_roughness=spec.relative_roughness, - gas=gas, - ) - for spec in specs - } - return TestMqlPnl00rAssembly(specs=specs, lines=lines) diff --git a/app/simulation/examples/test_mql/pneumatic_topology.py b/app/simulation/examples/test_mql/pneumatic_topology.py deleted file mode 100644 index 408133a..0000000 --- a/app/simulation/examples/test_mql/pneumatic_topology.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -from app.simulation.examples.test_mql.closure import TestMqlPneumaticChamberSegmentSpec -from app.simulation.examples.test_mql.topology import TestMqlCirTopology - - -def discover_fixed_chamber_segments( - topology: TestMqlCirTopology, - component_specs: list[dict[str, object]], - connection_specs: list[dict[str, object]], -) -> tuple[TestMqlPneumaticChamberSegmentSpec, ...]: - submodel_by_alias = { - str(component["alias"]): str(component["submodel"]) - for component in component_specs - } - segments = [] - for component in component_specs: - volume_alias = str(component["alias"]) - if component["submodel"] != "PNCH023": - continue - - orifice_contacts = [] - for contact in topology.contacts_for(volume_alias): - other_alias, other_port = contact.other_endpoint(volume_alias) - if submodel_by_alias.get(other_alias) == "PNOR001": - orifice_contacts.append( - ( - other_alias, - other_port, - contact.port_for(volume_alias), - ) - ) - if len(orifice_contacts) != 2: - raise ValueError( - f"{volume_alias} must contact exactly two PNOR001 orifices; " - f"found {len(orifice_contacts)}" - ) - - sides = [ - _resolve_orifice_boundary( - orifice_alias=orifice_alias, - orifice_volume_port=orifice_volume_port, - volume_port=volume_port, - connection_specs=connection_specs, - submodel_by_alias=submodel_by_alias, - ) - for orifice_alias, orifice_volume_port, volume_port in orifice_contacts - ] - inlet_sides = [side for side in sides if side["role"] == "inlet"] - outlet_sides = [side for side in sides if side["role"] == "outlet"] - if len(inlet_sides) != 1 or len(outlet_sides) != 1: - raise ValueError( - f"{volume_alias} requires one inlet and one outlet topology side" - ) - inlet = inlet_sides[0] - outlet = outlet_sides[0] - segments.append( - TestMqlPneumaticChamberSegmentSpec( - name=f"{volume_alias}_segment", - inlet_node_alias=inlet["node_alias"], - inlet_line_alias=inlet["line_alias"], - inlet_orifice_alias=inlet["orifice_alias"], - inlet_orifice_boundary_port=inlet["orifice_boundary_port"], - inlet_orifice_volume_port=inlet["orifice_volume_port"], - volume_alias=volume_alias, - volume_inlet_port=inlet["volume_port"], - volume_outlet_port=outlet["volume_port"], - outlet_orifice_alias=outlet["orifice_alias"], - outlet_orifice_volume_port=outlet["orifice_volume_port"], - outlet_orifice_boundary_port=outlet["orifice_boundary_port"], - outlet_line_alias=outlet["line_alias"], - outlet_node_alias=outlet["node_alias"], - ) - ) - return tuple(segments) - - -def _resolve_orifice_boundary( - *, - orifice_alias: str, - orifice_volume_port: str, - volume_port: str, - connection_specs: list[dict[str, object]], - submodel_by_alias: dict[str, str], -) -> dict[str, str]: - boundary_connections = [] - for connection in connection_specs: - if ( - connection["source_component"] == orifice_alias - and connection["source_port"] != orifice_volume_port - ) or ( - connection["target_component"] == orifice_alias - and connection["target_port"] != orifice_volume_port - ): - boundary_connections.append(connection) - if len(boundary_connections) != 1: - raise ValueError( - f"{orifice_alias} must have exactly one non-volume boundary connection; " - f"found {len(boundary_connections)}" - ) - - connection = boundary_connections[0] - if connection["submodel"] != "PNL0001": - raise ValueError( - f"{orifice_alias} boundary must use PNL0001, got {connection['submodel']}" - ) - if connection["target_component"] == orifice_alias: - role = "inlet" - node_alias = str(connection["source_component"]) - orifice_boundary_port = str(connection["target_port"]) - else: - role = "outlet" - node_alias = str(connection["target_component"]) - orifice_boundary_port = str(connection["source_port"]) - if submodel_by_alias.get(node_alias) != "PN3NODE2": - raise ValueError( - f"{orifice_alias} PNL0001 boundary must terminate at PN3NODE2, " - f"got {node_alias}" - ) - return { - "role": role, - "node_alias": node_alias, - "line_alias": str(connection["alias"]), - "orifice_alias": orifice_alias, - "orifice_boundary_port": orifice_boundary_port, - "orifice_volume_port": orifice_volume_port, - "volume_port": volume_port, - } diff --git a/app/simulation/examples/test_mql/primitives/__init__.py b/app/simulation/examples/test_mql/primitives/__init__.py deleted file mode 100644 index 64650fa..0000000 --- a/app/simulation/examples/test_mql/primitives/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Calibrated component primitives used only by the ``test_mql`` example.""" diff --git a/app/simulation/examples/test_mql/primitives/mechanical.py b/app/simulation/examples/test_mql/primitives/mechanical.py deleted file mode 100644 index 2f3c4a4..0000000 --- a/app/simulation/examples/test_mql/primitives/mechanical.py +++ /dev/null @@ -1,168 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from math import pi - - -MM_TO_M = 1.0e-3 -M_TO_MM = 1.0e3 -M3_TO_CM3 = 1.0e6 -M3_PER_S_TO_L_PER_MIN = 60_000.0 - - -def circular_area(diameter_m: float) -> float: - if diameter_m < 0.0: - raise ValueError("diameter_m must be non-negative.") - return pi * diameter_m * diameter_m / 4.0 - - -def mm_to_m(value: float) -> float: - return value * MM_TO_M - - -def m_to_mm(value: float) -> float: - return value * M_TO_MM - - -@dataclass(frozen=True) -class AmesimPistonGeometry: - """Geometry relations used by AMESim PNRP17 pneumatic piston variables.""" - - piston_diameter_m: float - rod_diameter_m: float = 0.0 - zero_length_m: float = 0.0 - - @property - def piston_area_m2(self) -> float: - return circular_area(self.piston_diameter_m) - - @property - def rod_area_m2(self) -> float: - return circular_area(self.rod_diameter_m) - - @property - def annulus_area_m2(self) -> float: - return self.piston_area_m2 - self.rod_area_m2 - - def chamber_length_m(self, port4_displacement_m: float, port5_displacement_m: float) -> float: - return self.zero_length_m + port5_displacement_m - port4_displacement_m - - def chamber_length_mm(self, port4_displacement_m: float, port5_displacement_m: float) -> float: - return m_to_mm(self.chamber_length_m(port4_displacement_m, port5_displacement_m)) - - @property - def chamber_area_m2(self) -> float: - return self.annulus_area_m2 - - def chamber_volume_m3(self, port4_displacement_m: float, port5_displacement_m: float) -> float: - return self.chamber_area_m2 * self.chamber_length_m( - port4_displacement_m, - port5_displacement_m, - ) - - def chamber_volume_cm3(self, port4_displacement_m: float, port5_displacement_m: float) -> float: - return self.chamber_volume_m3(port4_displacement_m, port5_displacement_m) * M3_TO_CM3 - - def chamber_volume_rate_m3_s(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float: - return self.chamber_area_m2 * (port5_velocity_m_s - port4_velocity_m_s) - - def chamber_volume_rate_l_min(self, port4_velocity_m_s: float, port5_velocity_m_s: float) -> float: - return self.chamber_volume_rate_m3_s( - port4_velocity_m_s, - port5_velocity_m_s, - ) * M3_PER_S_TO_L_PER_MIN - - -@dataclass(frozen=True) -class AmesimElasticEndstop: - """Contact force part of AMESim LSTP00A elastic endstop.""" - - contact_stiffness_n_per_m: float - contact_damping_n_per_m_per_s: float = 0.0 - gap0_m: float = 0.0 - - def penetration_m_from_gap_mm(self, gap_mm: float) -> float: - return max(-(mm_to_m(gap_mm) - self.gap0_m), 0.0) - - def static_contact_force(self, gap_mm: float) -> float: - return self.contact_stiffness_n_per_m * self.penetration_m_from_gap_mm(gap_mm) - - def contact_force(self, gap_mm: float, penetration_velocity_m_s: float = 0.0) -> float: - if self.penetration_m_from_gap_mm(gap_mm) <= 0.0: - return 0.0 - damping_force = self.contact_damping_n_per_m_per_s * penetration_velocity_m_s - return max(self.static_contact_force(gap_mm) + damping_force, 0.0) - - -@dataclass(frozen=True) -class AmesimMassFrictionEndstops: - """Parameter and observable helpers for AMESim MECMAS21 translation masses.""" - - mass_kg: float - lower_limit_m: float - upper_limit_m: float - lower_stiffness_n_per_m: float - upper_stiffness_n_per_m: float - lower_damping_n_per_m_per_s: float = 0.0 - upper_damping_n_per_m_per_s: float = 0.0 - viscous_friction_n_per_m_per_s: float = 0.0 - coulomb_friction_n: float = 0.0 - stiction_force_n: float = 0.0 - windage_n_per_m2_per_s2: float = 0.0 - - def lower_penetration_m(self, displacement_m: float) -> float: - return max(self.lower_limit_m - displacement_m, 0.0) - - def upper_penetration_m(self, displacement_m: float) -> float: - return max(displacement_m - self.upper_limit_m, 0.0) - - def lower_static_force_magnitude(self, displacement_m: float) -> float: - return self.lower_stiffness_n_per_m * self.lower_penetration_m(displacement_m) - - def upper_static_force_magnitude(self, displacement_m: float) -> float: - return self.upper_stiffness_n_per_m * self.upper_penetration_m(displacement_m) - - def viscous_friction_force(self, velocity_m_s: float) -> float: - return -self.viscous_friction_n_per_m_per_s * velocity_m_s - - def windage_force(self, velocity_m_s: float) -> float: - return -self.windage_n_per_m2_per_s2 * velocity_m_s * abs(velocity_m_s) - - def dry_friction_force(self, velocity_m_s: float) -> float: - if velocity_m_s > 0.0: - return -self.coulomb_friction_n - if velocity_m_s < 0.0: - return self.coulomb_friction_n - return 0.0 - - def limit_contact_force(self, displacement_m: float, velocity_m_s: float) -> float: - lower_force = self.lower_static_force_magnitude(displacement_m) - if lower_force > 0.0: - lower_force += max(-self.lower_damping_n_per_m_per_s * velocity_m_s, 0.0) - - upper_force = self.upper_static_force_magnitude(displacement_m) - if upper_force > 0.0: - upper_force += max(self.upper_damping_n_per_m_per_s * velocity_m_s, 0.0) - - return lower_force - upper_force - - def derivatives( - self, - *, - velocity_m_s: float, - displacement_m: float, - port_1_force_n: float = 0.0, - port_2_force_n: float = 0.0, - external_force_n: float = 0.0, - ) -> tuple[float, float]: - total_force = ( - port_1_force_n - + port_2_force_n - + external_force_n - + self.viscous_friction_force(velocity_m_s) - + self.windage_force(velocity_m_s) - + self.dry_friction_force(velocity_m_s) - + self.limit_contact_force(displacement_m, velocity_m_s) - ) - return total_force / self.mass_kg, velocity_m_s - diff --git a/app/simulation/examples/test_mql/primitives/pneumatic.py b/app/simulation/examples/test_mql/primitives/pneumatic.py deleted file mode 100644 index cd64f98..0000000 --- a/app/simulation/examples/test_mql/primitives/pneumatic.py +++ /dev/null @@ -1,437 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from math import pi, sqrt - -from app.simulation.core.base import AlgebraicComponent, DynamicComponent -from app.simulation.core.medium import ThermodynamicProperties -from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid -from app.simulation.core.ports import PortState -from app.simulation.core.state import VolumeState - - -@dataclass(frozen=True) -class AmesimPneumaticGas: - """Caloric constants plus Peng-Robinson EOS for AMESim pneumatic components.""" - - fluid: PengRobinsonFluid = HELIUM_PR - cp: float = 5193.0 - cv: float = 3116.0 - - @property - def gamma(self) -> float: - return self.cp / self.cv - - @property - def R_gas(self) -> float: - return self.fluid.specific_gas_constant - - def density(self, pressure: float, temperature: float) -> float: - return self.fluid.density(pressure, temperature) - - def pressure(self, density: float, temperature: float) -> float: - return self.fluid.pressure_from_density(temperature, density) - - def specific_internal_energy(self, temperature: float) -> float: - return self.cv * temperature - - def specific_enthalpy(self, temperature: float) -> float: - return self.cp * temperature - - def specific_reference_enthalpy( - self, - temperature: float, - reference_temperature: float = 298.15, - ) -> float: - return self.cp * (temperature - reference_temperature) - - def reference_temperature_from_specific_enthalpy( - self, - specific_enthalpy: float, - reference_temperature: float = 298.15, - ) -> float: - if self.cp <= 0.0: - raise ValueError("cp must be positive.") - return reference_temperature + specific_enthalpy / self.cp - - def pressure_reference_enthalpy( - self, - pressure: float, - temperature: float, - reference_pressure: float = 101_300.0, - reference_temperature: float = 298.15, - ) -> float: - return ( - self.specific_reference_enthalpy(temperature, reference_temperature) - + self.fluid.residual_specific_enthalpy(pressure, temperature) - - self.fluid.residual_specific_enthalpy( - reference_pressure, - reference_temperature, - ) - ) - - def pressure_transport_enthalpy( - self, - pressure: float, - temperature: float, - reference_pressure: float = 101_300.0, - reference_temperature: float = 298.15, - ) -> float: - """Convert AMESim reference enthalpy to the absolute-energy state basis.""" - return ( - self.pressure_reference_enthalpy( - pressure, - temperature, - reference_pressure, - reference_temperature, - ) - + self.cp * reference_temperature - ) - - def temperature_from_internal_energy(self, specific_internal_energy: float) -> float: - if self.cv <= 0.0: - raise ValueError("cv must be positive.") - return specific_internal_energy / self.cv - - -HELIUM_PNEUMATIC_GAS = AmesimPneumaticGas() - - -def liters_to_m3(value: float) -> float: - return value * 1.0e-3 - - -def m3_to_cm3(value: float) -> float: - return value * 1.0e6 - - -def cm3_to_m3(value: float) -> float: - return value * 1.0e-6 - - -def kg_to_g(value: float) -> float: - return value * 1.0e3 - - -def mm2_to_m2(value: float) -> float: - return value * 1.0e-6 - - -def diameter_mm_to_area_m2(diameter_mm: float) -> float: - diameter_m = diameter_mm * 1.0e-3 - return pi * diameter_m * diameter_m / 4.0 - - -class AmesimPneumaticVolume(DynamicComponent): - """First-pass AMESim pneumatic control volume using helium PR pressure closure.""" - - def __init__( - self, - name: str, - volume: float, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - p0: float = 101_325.0, - T0: float = 293.15, - heat_transfer_coefficient: float = 0.0, - heat_transfer_area: float = 0.0, - external_temperature_k: float = 293.15, - ) -> None: - if volume <= 0.0: - raise ValueError("volume must be positive.") - if heat_transfer_coefficient < 0.0: - raise ValueError("heat_transfer_coefficient must be non-negative.") - if heat_transfer_area < 0.0: - raise ValueError("heat_transfer_area must be non-negative.") - if external_temperature_k <= 0.0: - raise ValueError("external_temperature_k must be positive.") - super().__init__(name=name) - self.volume = volume - self.gas = gas - self.heat_transfer_coefficient = heat_transfer_coefficient - self.heat_transfer_area = heat_transfer_area - self.external_temperature = external_temperature_k - rho0 = gas.density(p0, T0) - m0 = rho0 * volume - U0 = m0 * gas.specific_internal_energy(T0) - self.state = VolumeState(m=m0, U=U0) - self.port_a = PortState() - self.port_b = PortState() - - @classmethod - def from_liters( - cls, - name: str, - volume_liters: float, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - p0: float = 101_325.0, - T0: float = 293.15, - heat_transfer_coefficient: float = 0.0, - heat_transfer_area: float = 0.0, - external_temperature_k: float = 293.15, - ) -> "AmesimPneumaticVolume": - return cls( - name=name, - volume=liters_to_m3(volume_liters), - gas=gas, - p0=p0, - T0=T0, - heat_transfer_coefficient=heat_transfer_coefficient, - heat_transfer_area=heat_transfer_area, - external_temperature_k=external_temperature_k, - ) - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def volume_cm3(self) -> float: - return m3_to_cm3(self.volume) - - def volume_rate_m3_s(self) -> float: - return 0.0 - - def thermal_energy_flow_w(self, temperature_k: float | None = None) -> float: - temperature = self.properties().T if temperature_k is None else temperature_k - return ( - self.heat_transfer_coefficient - * self.heat_transfer_area - * (self.external_temperature - temperature) - ) - - def gas_mass_g(self) -> float: - return kg_to_g(self.state.m) - - def pressure_gauge_pa(self, reference_pressure_pa: float = 101_300.0) -> float: - return self.properties().p - reference_pressure_pa - - def properties(self) -> ThermodynamicProperties: - if self.state.m <= 0.0: - raise ValueError("volume mass must stay positive.") - T = self.gas.temperature_from_internal_energy(self.state.U / self.state.m) - rho = self.state.m / self.volume - p = self.gas.pressure(rho, T) - u = self.state.U / self.state.m - h = self.gas.specific_enthalpy(T) - self.port_a.p = p - self.port_a.h_outflow = h - self.port_b.p = p - self.port_b.h_outflow = h - return ThermodynamicProperties(p=p, T=T, rho=rho, u=u, h=h) - - def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState: - return VolumeState( - m=m_flow, - U=m_flow * inlet_h + self.thermal_energy_flow_w(), - ) - - def derivatives_from_two_connections( - self, - *, - port_a_m_flow: float, - connected_h_a: float, - port_b_m_flow: float, - connected_h_b: float, - internal_h: float, - volume_rate_m3_s: float | None = None, - ) -> VolumeState: - properties = self.properties() - inlet_h_a = self.connection_inlet_enthalpy( - port_m_flow=port_a_m_flow, - connected_h=connected_h_a, - internal_h=internal_h, - ) - inlet_h_b = self.connection_inlet_enthalpy( - port_m_flow=port_b_m_flow, - connected_h=connected_h_b, - internal_h=internal_h, - ) - return VolumeState( - m=port_a_m_flow + port_b_m_flow, - U=( - port_a_m_flow * inlet_h_a - + port_b_m_flow * inlet_h_b - + self.thermal_energy_flow_w(properties.T) - - properties.p * ( - self.volume_rate_m3_s() - if volume_rate_m3_s is None - else volume_rate_m3_s - ) - ), - ) - - -class AmesimVariablePneumaticVolume(AmesimPneumaticVolume): - """PNCH012-style volume with a dead volume plus an external moving volume.""" - - def __init__( - self, - name: str, - dead_volume: float, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - p0: float = 101_325.0, - T0: float = 293.15, - external_volume: float = 0.0, - heat_transfer_coefficient: float = 0.0, - heat_transfer_area: float = 0.0, - external_temperature_k: float = 293.15, - ) -> None: - if dead_volume <= 0.0: - raise ValueError("dead_volume must be positive.") - if dead_volume + external_volume <= 0.0: - raise ValueError("total volume must be positive.") - self.dead_volume = dead_volume - self.external_volume = external_volume - self.external_volume_rate = 0.0 - super().__init__( - name=name, - volume=dead_volume + external_volume, - gas=gas, - p0=p0, - T0=T0, - heat_transfer_coefficient=heat_transfer_coefficient, - heat_transfer_area=heat_transfer_area, - external_temperature_k=external_temperature_k, - ) - - @classmethod - def from_liters( - cls, - name: str, - dead_volume_liters: float, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - p0: float = 101_325.0, - T0: float = 293.15, - external_volume_liters: float = 0.0, - heat_transfer_coefficient: float = 0.0, - heat_transfer_area: float = 0.0, - external_temperature_k: float = 293.15, - ) -> "AmesimVariablePneumaticVolume": - return cls( - name=name, - dead_volume=liters_to_m3(dead_volume_liters), - gas=gas, - p0=p0, - T0=T0, - external_volume=liters_to_m3(external_volume_liters), - heat_transfer_coefficient=heat_transfer_coefficient, - heat_transfer_area=heat_transfer_area, - external_temperature_k=external_temperature_k, - ) - - def volume_rate_m3_s(self) -> float: - return self.external_volume_rate - - def set_external_volume_m3( - self, - external_volume: float, - external_volume_rate_m3_s: float = 0.0, - ) -> None: - if self.dead_volume + external_volume <= 0.0: - raise ValueError("total volume must be positive.") - self.external_volume = external_volume - self.external_volume_rate = external_volume_rate_m3_s - self.volume = self.dead_volume + self.external_volume - - -class AmesimPneumaticOrifice(AlgebraicComponent): - """First-pass PNOR001/PNVO001-style compressible helium orifice. - - This is a calibrated placeholder boundary for the Python port. It preserves - AMESim-style area and coefficient inputs, but final parity must be checked - against AMESim CSV results before treating it as numerically equivalent. - """ - - def __init__( - self, - name: str, - area: float, - flow_coefficient: float = 1.0, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - opening: float = 1.0, - ) -> None: - if area < 0.0: - raise ValueError("area must be non-negative.") - if flow_coefficient < 0.0: - raise ValueError("flow_coefficient must be non-negative.") - super().__init__(name=name) - self.area = area - self.flow_coefficient = flow_coefficient - self.gas = gas - self.opening = opening - self.port_a = PortState() - self.port_b = PortState() - - @classmethod - def from_mm2( - cls, - name: str, - area_mm2: float, - flow_coefficient: float = 1.0, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - opening: float = 1.0, - ) -> "AmesimPneumaticOrifice": - return cls( - name=name, - area=mm2_to_m2(area_mm2), - flow_coefficient=flow_coefficient, - gas=gas, - opening=opening, - ) - - @property - def effective_area(self) -> float: - opening = min(max(self.opening, 0.0), 1.0) - return self.area * opening - - def mass_flow(self, p_a: float, p_b: float, upstream_temperature: float) -> float: - if p_a == p_b or self.effective_area == 0.0 or self.flow_coefficient == 0.0: - return 0.0 - if p_a > p_b: - return compressible_orifice_mass_flow( - upstream_pressure=p_a, - downstream_pressure=p_b, - upstream_temperature=upstream_temperature, - area=self.effective_area, - flow_coefficient=self.flow_coefficient, - gas=self.gas, - ) - return -compressible_orifice_mass_flow( - upstream_pressure=p_b, - downstream_pressure=p_a, - upstream_temperature=upstream_temperature, - area=self.effective_area, - flow_coefficient=self.flow_coefficient, - gas=self.gas, - ) - - -def compressible_orifice_mass_flow( - *, - upstream_pressure: float, - downstream_pressure: float, - upstream_temperature: float, - area: float, - flow_coefficient: float, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, -) -> float: - if upstream_pressure <= 0.0 or downstream_pressure < 0.0: - raise ValueError("pressures must be non-negative and upstream pressure must be positive.") - if upstream_temperature <= 0.0: - raise ValueError("upstream_temperature must be positive.") - if area < 0.0 or flow_coefficient < 0.0: - raise ValueError("area and flow_coefficient must be non-negative.") - if downstream_pressure >= upstream_pressure or area == 0.0 or flow_coefficient == 0.0: - return 0.0 - - gamma = gas.gamma - pressure_ratio = max(downstream_pressure / upstream_pressure, 0.0) - critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0)) - coefficient = flow_coefficient * area * upstream_pressure / sqrt(gas.R_gas * upstream_temperature) - if pressure_ratio <= critical_ratio: - flow_function = sqrt(gamma) * (2.0 / (gamma + 1.0)) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0))) - else: - term = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** ((gamma + 1.0) / gamma) - flow_function = sqrt((2.0 * gamma / (gamma - 1.0)) * max(term, 0.0)) - return coefficient * flow_function diff --git a/app/simulation/examples/test_mql/primitives/pneumatic_lines.py b/app/simulation/examples/test_mql/primitives/pneumatic_lines.py deleted file mode 100644 index c0b0ef7..0000000 --- a/app/simulation/examples/test_mql/primitives/pneumatic_lines.py +++ /dev/null @@ -1,881 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from math import log10, pi, sqrt - -from app.simulation.examples.test_mql.primitives.pneumatic import ( - HELIUM_PNEUMATIC_GAS, - AmesimPneumaticGas, - compressible_orifice_mass_flow, - diameter_mm_to_area_m2, -) -from app.simulation.core.base import AlgebraicComponent, DynamicComponent -from app.simulation.core.medium import ThermodynamicProperties -from app.simulation.core.ports import PortState -from app.simulation.core.state import VolumeState - - -@dataclass(frozen=True) -class AmesimPnl0001Diagnostics: - mass_flow_kg_s: float - reynolds_number: float - gas_velocity_m_s: float - friction_factor: float - pressure_drop_pa: float - - -class _DarcyPipeResistanceMixin: - diameter: float - length: float - relative_roughness: float - area: float - - def _mass_flow_for_pressure_drop( - self, - pressure_drop_pa: float, - *, - density: float, - temperature: float, - ) -> float: - if pressure_drop_pa <= 0.0: - return 0.0 - upper = 1.0e-9 - while self._darcy_pressure_drop( - upper, - density=density, - temperature=temperature, - ) < pressure_drop_pa: - upper *= 10.0 - if upper > 1.0e3: - raise ValueError("unable to bracket pneumatic pipe resistance flow") - lower = 0.0 - for _ in range(48): - middle = 0.5 * (lower + upper) - if self._darcy_pressure_drop( - middle, - density=density, - temperature=temperature, - ) < pressure_drop_pa: - lower = middle - else: - upper = middle - return 0.5 * (lower + upper) - - def pn2pipefr_mass_flow( - self, - *, - port_1_pressure_pa: float, - port_1_temperature_k: float, - port_2_pressure_pa: float, - port_2_temperature_k: float, - length: float | None = None, - ) -> float: - pressure_difference = port_1_pressure_pa - port_2_pressure_pa - if pressure_difference == 0.0: - return 0.0 - upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa) - downstream_pressure = min(port_1_pressure_pa, port_2_pressure_pa) - upstream_temperature = ( - port_1_temperature_k - if pressure_difference > 0.0 - else port_2_temperature_k - ) - resistance_length = self.length if length is None else length - if resistance_length <= 0.0: - raise ValueError("length must be positive") - - def target_flow(mass_flow_kg_s: float) -> float: - reynolds = self._reynolds_number(mass_flow_kg_s, upstream_temperature) - friction_factor = self._friction_factor(reynolds) - flow_coefficient = sqrt( - self.diameter / (resistance_length * friction_factor) - ) - return compressible_orifice_mass_flow( - upstream_pressure=upstream_pressure, - downstream_pressure=downstream_pressure, - upstream_temperature=upstream_temperature, - area=self.area, - flow_coefficient=flow_coefficient, - gas=self.gas, - ) - - flow_coefficient = sqrt(self.diameter / (resistance_length * 0.02)) - magnitude = compressible_orifice_mass_flow( - upstream_pressure=upstream_pressure, - downstream_pressure=downstream_pressure, - upstream_temperature=upstream_temperature, - area=self.area, - flow_coefficient=flow_coefficient, - gas=self.gas, - ) - for _ in range(12): - next_magnitude = target_flow(magnitude) - if abs(next_magnitude - magnitude) <= max(1.0e-12, abs(magnitude) * 1.0e-9): - magnitude = next_magnitude - break - magnitude = 0.5 * (magnitude + next_magnitude) - return magnitude if pressure_difference > 0.0 else -magnitude - - def _darcy_pressure_drop( - self, - mass_flow_kg_s: float, - *, - density: float, - temperature: float, - ) -> float: - if mass_flow_kg_s == 0.0: - return 0.0 - reynolds = self._reynolds_number(mass_flow_kg_s, temperature) - friction_factor = self._friction_factor(reynolds) - velocity = mass_flow_kg_s / (density * self.area) - magnitude = ( - friction_factor - * (self.length / self.diameter) - * density - * velocity - * velocity - / 2.0 - ) - return magnitude if mass_flow_kg_s > 0.0 else -magnitude - - def _reynolds_number(self, mass_flow_kg_s: float, temperature: float) -> float: - viscosity = helium_dynamic_viscosity(temperature) - return 4.0 * abs(mass_flow_kg_s) / (pi * self.diameter * viscosity) - - def _friction_factor(self, reynolds_number: float) -> float: - if reynolds_number <= 0.0: - return 64_000_000.0 - laminar = 64.0 / reynolds_number - if reynolds_number <= 2_300.0: - return laminar - turbulent = 1.0 / ( - -1.8 - * log10( - (self.relative_roughness / 3.7) ** 1.11 - + 6.9 / reynolds_number - ) - ) ** 2 - if reynolds_number >= 4_000.0: - return turbulent - fraction = (reynolds_number - 2_300.0) / 1_700.0 - return laminar + fraction * (turbulent - laminar) - - -class AmesimPnl0001Pipe(_DarcyPipeResistanceMixin, DynamicComponent): - """Physical first-pass implementation of AMESim ``PNL0001`` (C-R). - - Port 2 owns the lumped gas storage. Port 1 is connected through a Darcy - resistance. Both connection mass flows use the simulation convention: - positive values enter the pipe storage. - - AMESim's proprietary ``pn2pipefr`` utility is represented by an - optional calibrated linear conductance when a model-specific baseline - supports it; otherwise the component falls back to an auditable - Darcy-Weisbach law. Both paths preserve the real geometry, state count, - mass/energy balance, heat-transfer parameter, and observable diagnostics. - """ - - def __init__( - self, - name: str, - *, - diameter_mm: float, - length_m: float, - relative_roughness: float, - polytropic_constant: float = 1.35, - heat_transfer_coefficient: float = 0.0, - external_temperature_k: float = 293.15, - calibrated_linear_conductance: float | None = None, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - p0: float = 101_325.0, - T0: float = 293.15, - ) -> None: - if diameter_mm <= 0.0: - raise ValueError("diameter_mm must be positive") - if length_m <= 0.0: - raise ValueError("length_m must be positive") - if relative_roughness < 0.0: - raise ValueError("relative_roughness must be non-negative") - if polytropic_constant <= 0.0: - raise ValueError("polytropic_constant must be positive") - if heat_transfer_coefficient < 0.0: - raise ValueError("heat_transfer_coefficient must be non-negative") - if external_temperature_k <= 0.0: - raise ValueError("external_temperature_k must be positive") - if ( - calibrated_linear_conductance is not None - and calibrated_linear_conductance <= 0.0 - ): - raise ValueError("calibrated_linear_conductance must be positive") - - super().__init__(name=name) - self.diameter = diameter_mm * 1.0e-3 - self.length = length_m - self.relative_roughness = relative_roughness - self.polytropic_constant = polytropic_constant - self.heat_transfer_coefficient = heat_transfer_coefficient - self.external_temperature = external_temperature_k - self.calibrated_linear_conductance = calibrated_linear_conductance - self.gas = gas - self.area = diameter_mm_to_area_m2(diameter_mm) - self.volume = self.area * self.length - self.heat_transfer_area = pi * self.diameter * self.length - - rho0 = gas.density(p0, T0) - mass0 = rho0 * self.volume - self.state = VolumeState( - m=mass0, - U=mass0 * gas.specific_internal_energy(T0), - ) - self.port_1 = PortState() - self.port_2 = PortState() - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - if self.state.m <= 0.0: - raise ValueError("pipe mass must stay positive") - temperature = self.gas.temperature_from_internal_energy( - self.state.U / self.state.m - ) - density = self.state.m / self.volume - pressure = self.gas.pressure(density, temperature) - properties = ThermodynamicProperties( - p=pressure, - T=temperature, - rho=density, - u=self.state.U / self.state.m, - h=self.gas.specific_enthalpy(temperature), - ) - self.port_2.p = pressure - self.port_2.h_outflow = properties.h - return properties - - def gas_mass_g(self) -> float: - return self.state.m * 1.0e3 - - def resistance_mass_flow( - self, - *, - port_1_pressure_pa: float, - port_1_temperature_k: float, - ) -> float: - """Return mass flow from port 1 into the port-2 storage in kg/s.""" - if port_1_pressure_pa <= 0.0: - raise ValueError("port_1_pressure_pa must be positive") - if port_1_temperature_k <= 0.0: - raise ValueError("port_1_temperature_k must be positive") - - internal = self.properties() - pressure_difference = port_1_pressure_pa - internal.p - if pressure_difference == 0.0: - return 0.0 - if self.calibrated_linear_conductance is not None: - return ( - self.calibrated_linear_conductance - * pressure_difference - / sqrt(internal.T) - ) - upstream_pressure = max(port_1_pressure_pa, internal.p) - upstream_temperature = ( - port_1_temperature_k if pressure_difference > 0.0 else internal.T - ) - density = self.gas.density(upstream_pressure, upstream_temperature) - magnitude = self._mass_flow_for_pressure_drop( - abs(pressure_difference), - density=density, - temperature=upstream_temperature, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def diagnostics( - self, - *, - mass_flow_kg_s: float, - temperature_k: float | None = None, - ) -> AmesimPnl0001Diagnostics: - properties = self.properties() - temperature = temperature_k or properties.T - reynolds = self._reynolds_number(mass_flow_kg_s, temperature) - friction_factor = self._friction_factor(reynolds) - velocity = mass_flow_kg_s / (properties.rho * self.area) - pressure_drop = self._darcy_pressure_drop( - mass_flow_kg_s, - density=properties.rho, - temperature=temperature, - ) - return AmesimPnl0001Diagnostics( - mass_flow_kg_s=mass_flow_kg_s, - reynolds_number=reynolds, - gas_velocity_m_s=velocity, - friction_factor=friction_factor, - pressure_drop_pa=pressure_drop, - ) - - def darcy_pressure_drop_for_state( - self, - *, - mass_flow_kg_s: float, - pressure_pa: float, - temperature_k: float, - ) -> float: - if pressure_pa <= 0.0: - raise ValueError("pressure_pa must be positive") - if temperature_k <= 0.0: - raise ValueError("temperature_k must be positive") - density = self.gas.density(pressure_pa, temperature_k) - return self._darcy_pressure_drop( - mass_flow_kg_s, - density=density, - temperature=temperature_k, - ) - - def derivatives_from_connections( - self, - *, - port_1_m_flow: float, - connected_h_1: float, - port_2_m_flow: float, - connected_h_2: float, - ) -> VolumeState: - internal = self.properties() - # Default first-pass PNL0001 behavior uses the historical internal-energy - # approximation. AMESim-specific transport-enthalpy corrections are kept - # behind derivatives_from_transport_enthalpy_connections so they can be - # applied only where validated against baseline data. - inlet_u_1 = ( - connected_h_1 / self.gas.gamma - if port_1_m_flow > 0.0 - else internal.u - ) - inlet_u_2 = ( - connected_h_2 / self.gas.gamma - if port_2_m_flow > 0.0 - else internal.u - ) - heat_flow = ( - self.heat_transfer_coefficient - * self.heat_transfer_area - * (self.external_temperature - internal.T) - ) - return VolumeState( - m=port_1_m_flow + port_2_m_flow, - U=port_1_m_flow * inlet_u_1 + port_2_m_flow * inlet_u_2 + heat_flow, - ) - - def derivatives_from_transport_enthalpy_connections( - self, - *, - port_1_m_flow: float, - connected_h_1: float, - port_2_m_flow: float, - connected_h_2: float, - ) -> VolumeState: - internal = self.properties() - inlet_h_1 = connected_h_1 if port_1_m_flow > 0.0 else internal.h - inlet_h_2 = connected_h_2 if port_2_m_flow > 0.0 else internal.h - heat_flow = ( - self.heat_transfer_coefficient - * self.heat_transfer_area - * (self.external_temperature - internal.T) - ) - return VolumeState( - m=port_1_m_flow + port_2_m_flow, - U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow, - ) - - -class AmesimPnl0003Pipe(_DarcyPipeResistanceMixin, DynamicComponent): - """First-pass AMESim ``PNL0003`` (C-R-C) pipe. - - The two pipe-end compliances are represented as equal half-volume gas - stores connected by the same auditable Darcy resistance used for PNL0001. - Center flow is positive from port 1 storage to port 2 storage. - """ - - state_size = 4 - - def __init__( - self, - name: str, - *, - diameter_mm: float, - length_m: float, - relative_roughness: float, - polytropic_constant: float = 1.35, - heat_transfer_coefficient: float = 0.0, - external_temperature_k: float = 293.15, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - p1_0: float = 101_325.0, - T1_0: float = 293.15, - p2_0: float = 101_325.0, - T2_0: float = 293.15, - ) -> None: - if diameter_mm <= 0.0: - raise ValueError("diameter_mm must be positive") - if length_m <= 0.0: - raise ValueError("length_m must be positive") - if relative_roughness < 0.0: - raise ValueError("relative_roughness must be non-negative") - if polytropic_constant <= 0.0: - raise ValueError("polytropic_constant must be positive") - if heat_transfer_coefficient < 0.0: - raise ValueError("heat_transfer_coefficient must be non-negative") - if external_temperature_k <= 0.0: - raise ValueError("external_temperature_k must be positive") - - super().__init__(name=name) - self.diameter = diameter_mm * 1.0e-3 - self.length = length_m - self.relative_roughness = relative_roughness - self.polytropic_constant = polytropic_constant - self.heat_transfer_coefficient = heat_transfer_coefficient - self.external_temperature = external_temperature_k - self.gas = gas - self.area = diameter_mm_to_area_m2(diameter_mm) - self.volume = self.area * self.length - self.compliance_volume = self.volume / 2.0 - self.heat_transfer_area = pi * self.diameter * self.length - - self.state_1 = self._initial_state(p1_0, T1_0) - self.state_2 = self._initial_state(p2_0, T2_0) - self.port_1 = PortState() - self.port_2 = PortState() - - def _initial_state(self, pressure: float, temperature: float) -> VolumeState: - rho = self.gas.density(pressure, temperature) - mass = rho * self.compliance_volume - return VolumeState( - m=mass, - U=mass * self.gas.specific_internal_energy(temperature), - ) - - def get_state_vector(self) -> list[float]: - return [*self.state_1.as_vector(), *self.state_2.as_vector()] - - def set_state_vector(self, values: list[float]) -> None: - if len(values) != 4: - raise ValueError("PNL0003 state vector requires four values") - self.state_1 = VolumeState.from_vector(values[:2]) - self.state_2 = VolumeState.from_vector(values[2:]) - - def properties_1(self) -> ThermodynamicProperties: - properties = self._properties(self.state_1) - self.port_1.p = properties.p - self.port_1.h_outflow = properties.h - return properties - - def properties_2(self) -> ThermodynamicProperties: - properties = self._properties(self.state_2) - self.port_2.p = properties.p - self.port_2.h_outflow = properties.h - return properties - - def _properties(self, state: VolumeState) -> ThermodynamicProperties: - if state.m <= 0.0: - raise ValueError("pipe mass must stay positive") - temperature = self.gas.temperature_from_internal_energy(state.U / state.m) - density = state.m / self.compliance_volume - pressure = self.gas.pressure(density, temperature) - return ThermodynamicProperties( - p=pressure, - T=temperature, - rho=density, - u=state.U / state.m, - h=self.gas.specific_enthalpy(temperature), - ) - - def gas_mass_g(self) -> float: - return (self.state_1.m + self.state_2.m) * 1.0e3 - - def resistance_mass_flow(self) -> float: - """Return center mass flow from port 1 storage to port 2 storage.""" - port_1 = self.properties_1() - port_2 = self.properties_2() - pressure_difference = port_1.p - port_2.p - if pressure_difference == 0.0: - return 0.0 - upstream = port_1 if pressure_difference > 0.0 else port_2 - magnitude = self._mass_flow_for_pressure_drop( - abs(pressure_difference), - density=upstream.rho, - temperature=upstream.T, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def diagnostics( - self, - *, - mass_flow_kg_s: float, - temperature_k: float | None = None, - ) -> AmesimPnl0001Diagnostics: - port_1 = self.properties_1() - port_2 = self.properties_2() - temperature = temperature_k or (port_1.T if mass_flow_kg_s >= 0.0 else port_2.T) - density = port_1.rho if mass_flow_kg_s >= 0.0 else port_2.rho - reynolds = self._reynolds_number(mass_flow_kg_s, temperature) - friction_factor = self._friction_factor(reynolds) - velocity = mass_flow_kg_s / (density * self.area) - pressure_drop = self._darcy_pressure_drop( - mass_flow_kg_s, - density=density, - temperature=temperature, - ) - return AmesimPnl0001Diagnostics( - mass_flow_kg_s=mass_flow_kg_s, - reynolds_number=reynolds, - gas_velocity_m_s=velocity, - friction_factor=friction_factor, - pressure_drop_pa=pressure_drop, - ) - - def derivatives_from_connections( - self, - *, - port_1_m_flow: float, - connected_h_1: float, - port_2_m_flow: float, - connected_h_2: float, - ) -> tuple[VolumeState, VolumeState]: - port_1 = self.properties_1() - port_2 = self.properties_2() - center_flow = self.resistance_mass_flow() - heat_flow_each = ( - self.heat_transfer_coefficient - * self.heat_transfer_area - * (self.external_temperature - 0.5 * (port_1.T + port_2.T)) - / 2.0 - ) - port_1_external_h = self.connection_inlet_enthalpy( - port_m_flow=port_1_m_flow, - connected_h=connected_h_1, - internal_h=port_1.h, - ) - port_2_external_h = self.connection_inlet_enthalpy( - port_m_flow=port_2_m_flow, - connected_h=connected_h_2, - internal_h=port_2.h, - ) - port_1_center_h = self.connection_inlet_enthalpy( - port_m_flow=-center_flow, - connected_h=port_2.h, - internal_h=port_1.h, - ) - port_2_center_h = self.connection_inlet_enthalpy( - port_m_flow=center_flow, - connected_h=port_1.h, - internal_h=port_2.h, - ) - return ( - VolumeState( - m=port_1_m_flow - center_flow, - U=( - port_1_m_flow * port_1_external_h - - center_flow * port_1_center_h - + heat_flow_each - ), - ), - VolumeState( - m=port_2_m_flow + center_flow, - U=( - port_2_m_flow * port_2_external_h - + center_flow * port_2_center_h - + heat_flow_each - ), - ), - ) - - -class AmesimPnl0002Pipe(_DarcyPipeResistanceMixin, DynamicComponent): - """First-pass AMESim ``PNL0002`` (R-C-R) pipe. - - The center compliance owns the gas state. Positive connection mass flows - enter that center storage from each external port. - """ - - state_size = 2 - - def __init__( - self, - name: str, - *, - diameter_mm: float, - length_m: float, - relative_roughness: float, - polytropic_constant: float = 1.35, - heat_transfer_coefficient: float = 0.0, - external_temperature_k: float = 293.15, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - pctr_0: float = 101_325.0, - Tctr_0: float = 293.15, - ) -> None: - if diameter_mm <= 0.0: - raise ValueError("diameter_mm must be positive") - if length_m <= 0.0: - raise ValueError("length_m must be positive") - if relative_roughness < 0.0: - raise ValueError("relative_roughness must be non-negative") - if polytropic_constant <= 0.0: - raise ValueError("polytropic_constant must be positive") - if heat_transfer_coefficient < 0.0: - raise ValueError("heat_transfer_coefficient must be non-negative") - if external_temperature_k <= 0.0: - raise ValueError("external_temperature_k must be positive") - - super().__init__(name=name) - self.diameter = diameter_mm * 1.0e-3 - self.length = length_m - self.relative_roughness = relative_roughness - self.polytropic_constant = polytropic_constant - self.heat_transfer_coefficient = heat_transfer_coefficient - self.external_temperature = external_temperature_k - self.gas = gas - self.area = diameter_mm_to_area_m2(diameter_mm) - self.volume = self.area * self.length - self.heat_transfer_area = pi * self.diameter * self.length - self._resistance_length = self.length / 2.0 - - rho0 = gas.density(pctr_0, Tctr_0) - mass0 = rho0 * self.volume - self.state = VolumeState( - m=mass0, - U=mass0 * gas.specific_internal_energy(Tctr_0), - ) - self.port_1 = PortState() - self.port_2 = PortState() - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - if self.state.m <= 0.0: - raise ValueError("pipe mass must stay positive") - temperature = self.gas.temperature_from_internal_energy( - self.state.U / self.state.m - ) - density = self.state.m / self.volume - pressure = self.gas.pressure(density, temperature) - properties = ThermodynamicProperties( - p=pressure, - T=temperature, - rho=density, - u=self.state.U / self.state.m, - h=self.gas.specific_enthalpy(temperature), - ) - self.port_1.p = pressure - self.port_1.h_outflow = properties.h - self.port_2.p = pressure - self.port_2.h_outflow = properties.h - return properties - - def gas_mass_g(self) -> float: - return self.state.m * 1.0e3 - - def port_mass_flow( - self, - *, - port_pressure_pa: float, - port_temperature_k: float, - ) -> float: - """Return mass flow from an external port into the center storage.""" - if port_pressure_pa <= 0.0: - raise ValueError("port_pressure_pa must be positive") - if port_temperature_k <= 0.0: - raise ValueError("port_temperature_k must be positive") - - center = self.properties() - pressure_difference = port_pressure_pa - center.p - if pressure_difference == 0.0: - return 0.0 - upstream_pressure = max(port_pressure_pa, center.p) - upstream_temperature = ( - port_temperature_k if pressure_difference > 0.0 else center.T - ) - density = self.gas.density(upstream_pressure, upstream_temperature) - magnitude = self._mass_flow_for_resistance_pressure_drop( - abs(pressure_difference), - density=density, - temperature=upstream_temperature, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def _mass_flow_for_resistance_pressure_drop( - self, - pressure_drop_pa: float, - *, - density: float, - temperature: float, - ) -> float: - original_length = self.length - self.length = self._resistance_length - try: - return self._mass_flow_for_pressure_drop( - pressure_drop_pa, - density=density, - temperature=temperature, - ) - finally: - self.length = original_length - - def diagnostics( - self, - *, - mass_flow_kg_s: float, - temperature_k: float | None = None, - ) -> AmesimPnl0001Diagnostics: - properties = self.properties() - temperature = temperature_k or properties.T - reynolds = self._reynolds_number(mass_flow_kg_s, temperature) - friction_factor = self._friction_factor(reynolds) - velocity = mass_flow_kg_s / (properties.rho * self.area) - original_length = self.length - self.length = self._resistance_length - try: - pressure_drop = self._darcy_pressure_drop( - mass_flow_kg_s, - density=properties.rho, - temperature=temperature, - ) - finally: - self.length = original_length - return AmesimPnl0001Diagnostics( - mass_flow_kg_s=mass_flow_kg_s, - reynolds_number=reynolds, - gas_velocity_m_s=velocity, - friction_factor=friction_factor, - pressure_drop_pa=pressure_drop, - ) - - def derivatives_from_connections( - self, - *, - port_1_m_flow: float, - connected_h_1: float, - port_2_m_flow: float, - connected_h_2: float, - ) -> VolumeState: - center = self.properties() - inlet_h_1 = self.connection_inlet_enthalpy( - port_m_flow=port_1_m_flow, - connected_h=connected_h_1, - internal_h=center.h, - ) - inlet_h_2 = self.connection_inlet_enthalpy( - port_m_flow=port_2_m_flow, - connected_h=connected_h_2, - internal_h=center.h, - ) - heat_flow = ( - self.heat_transfer_coefficient - * self.heat_transfer_area - * (self.external_temperature - center.T) - ) - return VolumeState( - m=port_1_m_flow + port_2_m_flow, - U=port_1_m_flow * inlet_h_1 + port_2_m_flow * inlet_h_2 + heat_flow, - ) - - -class AmesimPnl00rPipe(_DarcyPipeResistanceMixin, AlgebraicComponent): - """First-pass AMESim ``PNL00R`` (R) pipe resistance.""" - - def __init__( - self, - name: str, - *, - diameter_mm: float, - length_m: float, - relative_roughness: float, - gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS, - ) -> None: - if diameter_mm <= 0.0: - raise ValueError("diameter_mm must be positive") - if length_m <= 0.0: - raise ValueError("length_m must be positive") - if relative_roughness < 0.0: - raise ValueError("relative_roughness must be non-negative") - - super().__init__(name=name) - self.diameter = diameter_mm * 1.0e-3 - self.length = length_m - self.relative_roughness = relative_roughness - self.gas = gas - self.area = diameter_mm_to_area_m2(diameter_mm) - self.port_1 = PortState() - self.port_2 = PortState() - - def mass_flow( - self, - *, - port_1_pressure_pa: float, - port_1_temperature_k: float, - port_2_pressure_pa: float, - port_2_temperature_k: float, - ) -> float: - """Return mass flow from port 1 to port 2 in kg/s.""" - if port_1_pressure_pa <= 0.0 or port_2_pressure_pa <= 0.0: - raise ValueError("port pressures must be positive") - if port_1_temperature_k <= 0.0 or port_2_temperature_k <= 0.0: - raise ValueError("port temperatures must be positive") - pressure_difference = port_1_pressure_pa - port_2_pressure_pa - if pressure_difference == 0.0: - return 0.0 - upstream_pressure = max(port_1_pressure_pa, port_2_pressure_pa) - upstream_temperature = ( - port_1_temperature_k - if pressure_difference > 0.0 - else port_2_temperature_k - ) - density = self.gas.density(upstream_pressure, upstream_temperature) - magnitude = self._mass_flow_for_pressure_drop( - abs(pressure_difference), - density=density, - temperature=upstream_temperature, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def diagnostics( - self, - *, - mass_flow_kg_s: float, - pressure_pa: float, - temperature_k: float, - ) -> AmesimPnl0001Diagnostics: - density = self.gas.density(pressure_pa, temperature_k) - reynolds = self._reynolds_number(mass_flow_kg_s, temperature_k) - friction_factor = self._friction_factor(reynolds) - velocity = mass_flow_kg_s / (density * self.area) - pressure_drop = self._darcy_pressure_drop( - mass_flow_kg_s, - density=density, - temperature=temperature_k, - ) - return AmesimPnl0001Diagnostics( - mass_flow_kg_s=mass_flow_kg_s, - reynolds_number=reynolds, - gas_velocity_m_s=velocity, - friction_factor=friction_factor, - pressure_drop_pa=pressure_drop, - ) - - -def helium_dynamic_viscosity(temperature_k: float) -> float: - """Sutherland approximation centered on the test_mql initial condition.""" - if temperature_k <= 0.0: - raise ValueError("temperature_k must be positive") - reference_temperature = 293.15 - reference_viscosity = 2.0e-5 - sutherland_constant = 79.4 - return ( - reference_viscosity - * (temperature_k / reference_temperature) ** 1.5 - * (reference_temperature + sutherland_constant) - / (temperature_k + sutherland_constant) - ) diff --git a/app/simulation/examples/test_mql/run.py b/app/simulation/examples/test_mql/run.py deleted file mode 100644 index 3d3dec3..0000000 --- a/app/simulation/examples/test_mql/run.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path - -from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR -from app.simulation.examples.test_mql.system import ( - TestMqlRunConfig, - TestMqlSimulationResult, - TestMqlSystem, -) - - -@dataclass(frozen=True) -class PreparedTestMqlRun: - run_config: TestMqlRunConfig - repo_root: Path - output_dir: Path - - -@dataclass(frozen=True) -class TestMqlRunResult: - run_config: TestMqlRunConfig - prepared_run: PreparedTestMqlRun - system: TestMqlSystem - result: TestMqlSimulationResult - summary_path: Path - - -def format_test_mql_summary(system: TestMqlSystem) -> str: - snapshot = system.snapshot() - lines = [ - "Model: test_mql", - f"Source archive: {system.archive_path}", - f"Components: {snapshot.component_count}", - f"Connections: {snapshot.connection_count}", - f"Continuous states in AMESim modelinfo: {snapshot.continuous_state_count}", - f"Discrete states in AMESim modelinfo: {snapshot.discrete_state_count}", - "Global parameters:", - ] - for name, value in sorted(snapshot.global_parameters.items()): - lines.append(f" - {name}: {value}") - lines.append("Component submodels:") - for name, count in sorted(snapshot.submodel_counts.items()): - lines.append(f" - {name}: {count}") - return "\n".join(lines) + "\n" - - -def _default_run_output_dir() -> Path: - timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f") - return SIMULATION_RUNS_DIR / timestamp - - -def prepare_test_mql_run( - *, - run_config: TestMqlRunConfig | None = None, - output_dir: Path | None = None, -) -> PreparedTestMqlRun: - return PreparedTestMqlRun( - run_config=run_config or TestMqlRunConfig(), - repo_root=PROJECT_ROOT, - output_dir=output_dir or _default_run_output_dir(), - ) - - -def run_prepared_test_mql(prepared_run: PreparedTestMqlRun) -> TestMqlRunResult: - system = TestMqlSystem() - result = system.simulate(prepared_run.run_config) - prepared_run.output_dir.mkdir(parents=True, exist_ok=True) - summary_path = prepared_run.output_dir / "test_mql_model_summary.txt" - summary_path.write_text(format_test_mql_summary(system), encoding="utf-8") - return TestMqlRunResult( - run_config=prepared_run.run_config, - prepared_run=prepared_run, - system=system, - result=result, - summary_path=summary_path, - ) - - -def run_test_mql( - *, - run_config: TestMqlRunConfig | None = None, - output_dir: Path | None = None, -) -> TestMqlRunResult: - return run_prepared_test_mql( - prepare_test_mql_run(run_config=run_config, output_dir=output_dir) - ) - - -def main() -> None: - run = run_test_mql() - print(format_test_mql_summary(run.system), end="") - print(f"Samples: {len(run.result.t)}") - print(f"Output directory: {run.prepared_run.output_dir}") - - -if __name__ == "__main__": - main() diff --git a/app/simulation/examples/test_mql/run_baseline.py b/app/simulation/examples/test_mql/run_baseline.py deleted file mode 100644 index eed6a57..0000000 --- a/app/simulation/examples/test_mql/run_baseline.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import UTC, datetime -from pathlib import Path - -from app.simulation.examples.test_mql.baseline import ( - TestMqlBaselineRun, - run_test_mql_baseline_passthrough, -) -from app.simulation.paths import ( - AMESIM_TEST_MQL_ARCHIVE_PATH, - SIMULATION_RUNS_DIR, -) - - -@dataclass(frozen=True) -class TestMqlBaselinePathConfig: - archive_path: Path = field( - default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH - ) - output_dir: Path | None = None - - -@dataclass(frozen=True) -class TestMqlBaselineExecutionConfig: - write_summary: bool = True - data_paths: tuple[str, ...] | None = None - - -@dataclass(frozen=True) -class TestMqlBaselineScriptConfig: - paths: TestMqlBaselinePathConfig = field(default_factory=TestMqlBaselinePathConfig) - execution: TestMqlBaselineExecutionConfig = field(default_factory=TestMqlBaselineExecutionConfig) - - -def _default_output_dir() -> Path: - timestamp = datetime.now(UTC).strftime("test_mql_baseline_%Y%m%d_%H%M%S_%f") - return SIMULATION_RUNS_DIR / timestamp - - -def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str: - return "\n".join( - [ - "Model: test_mql", - "Mode: AMESim baseline passthrough", - f"Samples: {run.sample_count}", - f"Output schema signals: {run.output_schema.signal_count}", - f"Compared signals: {run.signal_count}", - f"Observation bindings: {run.observation_catalog.binding_count}", - f"Max absolute error: {run.comparison.max_abs_error}", - f"Max relative error: {run.comparison.max_rel_error}", - ] - ) + "\n" - - -def run_test_mql_baseline(config: TestMqlBaselineScriptConfig | None = None): - config = config or TestMqlBaselineScriptConfig() - run = run_test_mql_baseline_passthrough( - config.paths.archive_path, - data_paths=config.execution.data_paths, - ) - output_dir = config.paths.output_dir or _default_output_dir() - if config.execution.write_summary: - output_dir.mkdir(parents=True, exist_ok=True) - (output_dir / "test_mql_baseline_summary.txt").write_text( - format_test_mql_baseline_summary(run), - encoding="utf-8", - ) - return run, output_dir - - -def main() -> None: - run, output_dir = run_test_mql_baseline() - print(format_test_mql_baseline_summary(run), end="") - print(f"Output directory: {output_dir}") - - -if __name__ == "__main__": - main() diff --git a/app/simulation/examples/test_mql/run_full_state_comparison.py b/app/simulation/examples/test_mql/run_full_state_comparison.py deleted file mode 100644 index a24083c..0000000 --- a/app/simulation/examples/test_mql/run_full_state_comparison.py +++ /dev/null @@ -1,4621 +0,0 @@ -from __future__ import annotations - -import argparse -from bisect import bisect_left -from dataclasses import dataclass, field, replace -from datetime import UTC, datetime -from math import nextafter, sqrt -from pathlib import Path - -from app.simulation.examples.test_mql.primitives.pneumatic import AmesimPneumaticGas -from app.simulation.examples.test_mql.solver import SolveIVPConfig, integrate_ode -from app.simulation.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results -from app.simulation.reporting.test_mql_comparison import ( - TestMqlComparisonResult, - interpolate_series_value, - write_test_mql_comparison_csv, -) -from app.simulation.reporting.test_mql_output_schema import ( - TestMqlOutputSchema, - build_test_mql_output_schema, -) -from app.simulation.reporting.test_mql_output_validation import ( - TestMqlValidatedOutput, - compare_validated_test_mql_output, - validate_test_mql_output, -) -from app.simulation.examples.test_mql.system import ( - TestMqlPnl0001LineRhsDiagnostic, - TestMqlSimulationResult, - TestMqlSystem, - TestMqlVariableChamberRhsDiagnostic, -) -from app.simulation.examples.test_mql.pneumatic import AMESIM_REFERENCE_PRESSURE_PA -from app.simulation.paths import ( - AMESIM_TEST_MQL_ARCHIVE_PATH, - SIMULATION_RUNS_DIR, -) - - -DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS = ( - "press@pn_c1_8", - "vol@pn_c1_8", - "vol1@pn_brp2_8", - "vvol1@pn_brp2_8", - "x1@mass_friction_endstops_10", - "v1@mass_friction_endstops_10", - "acc1@mass_friction_endstops_10", - "x1@mass_friction_endstops_18", - "v1@mass_friction_endstops_18", - "acc1@mass_friction_endstops_18", - "dm1@pneumatic_69", - "xv@pn_morifice_1", - "dm2@pn_morifice_1", -) - - -@dataclass(frozen=True) -class TestMqlFullStateSignalDiagnostic: - data_path: str - initial_time_s: float - final_time_s: float - initial_python_value: float - initial_amesim_value: float - initial_abs_error: float - final_python_value: float - final_amesim_value: float - final_abs_error: float - - -@dataclass(frozen=True) -class TestMqlFullStateFlowDiagnostic: - data_path: str - initial_time_s: float - final_time_s: float - initial_python_dm1_g_s: float - initial_amesim_dm1_g_s: float - initial_python_canonical_kg_s: float - initial_amesim_canonical_kg_s: float - initial_canonical_abs_error_kg_s: float - final_python_dm1_g_s: float - final_amesim_dm1_g_s: float - final_python_canonical_kg_s: float - final_amesim_canonical_kg_s: float - final_canonical_abs_error_kg_s: float - - -@dataclass(frozen=True) -class TestMqlFullStatePnvoDiagnostic: - alias: str - initial_time_s: float - final_time_s: float - initial_python_opening: float - initial_amesim_opening: float - final_python_opening: float - final_amesim_opening: float - initial_python_mass_flow_kg_s: float - initial_amesim_mass_flow_kg_s: float - final_python_mass_flow_kg_s: float - final_amesim_mass_flow_kg_s: float - final_mass_flow_abs_error_kg_s: float - - -@dataclass(frozen=True) -class TestMqlFullStateComparisonRun: - system: TestMqlSystem - closure: object - amesim_results: AmesimResults - output_schema: TestMqlOutputSchema - result: TestMqlSimulationResult - output: TestMqlValidatedOutput - comparison: TestMqlComparisonResult - - @property - def sample_count(self) -> int: - return len(self.output.times) - - @property - def signal_count(self) -> int: - return len(self.output.data_paths) - - def metrics_by_max_abs_error(self): - return tuple( - sorted( - self.comparison.metrics, - key=lambda metric: metric.max_abs_error, - reverse=True, - ) - ) - - @property - def largest_abs_error_metric(self): - metrics = self.metrics_by_max_abs_error() - return metrics[0] if metrics else None - - def signal_diagnostic(self, data_path: str) -> TestMqlFullStateSignalDiagnostic: - times = self.output.times - python_values = self.output.series_by_data_path[data_path] - amesim_values = self.amesim_results.series(data_path) - initial_time = float(times[0]) - final_time = float(times[-1]) - initial_python = float(python_values[0]) - final_python = float(python_values[-1]) - initial_amesim = interpolate_series_value( - self.amesim_results.times, - amesim_values, - initial_time, - ) - final_amesim = interpolate_series_value( - self.amesim_results.times, - amesim_values, - final_time, - ) - return TestMqlFullStateSignalDiagnostic( - data_path=data_path, - initial_time_s=initial_time, - final_time_s=final_time, - initial_python_value=initial_python, - initial_amesim_value=initial_amesim, - initial_abs_error=abs(initial_python - initial_amesim), - final_python_value=final_python, - final_amesim_value=final_amesim, - final_abs_error=abs(final_python - final_amesim), - ) - - def pnl0001_mass_flow_diagnostic( - self, - data_path: str = "dm1@pneumatic_69", - ) -> TestMqlFullStateFlowDiagnostic: - diagnostic = self.signal_diagnostic(data_path) - initial_python_canonical = -diagnostic.initial_python_value * 1.0e-3 - initial_amesim_canonical = -diagnostic.initial_amesim_value * 1.0e-3 - final_python_canonical = -diagnostic.final_python_value * 1.0e-3 - final_amesim_canonical = -diagnostic.final_amesim_value * 1.0e-3 - return TestMqlFullStateFlowDiagnostic( - data_path=data_path, - initial_time_s=diagnostic.initial_time_s, - final_time_s=diagnostic.final_time_s, - initial_python_dm1_g_s=diagnostic.initial_python_value, - initial_amesim_dm1_g_s=diagnostic.initial_amesim_value, - initial_python_canonical_kg_s=initial_python_canonical, - initial_amesim_canonical_kg_s=initial_amesim_canonical, - initial_canonical_abs_error_kg_s=abs( - initial_python_canonical - initial_amesim_canonical - ), - final_python_dm1_g_s=diagnostic.final_python_value, - final_amesim_dm1_g_s=diagnostic.final_amesim_value, - final_python_canonical_kg_s=final_python_canonical, - final_amesim_canonical_kg_s=final_amesim_canonical, - final_canonical_abs_error_kg_s=abs( - final_python_canonical - final_amesim_canonical - ), - ) - - def pnvo_diagnostic( - self, - alias: str = "pn_morifice_1", - ) -> TestMqlFullStatePnvoDiagnostic: - opening = self.signal_diagnostic(f"xv@{alias}") - mass_flow = self.signal_diagnostic(f"dm2@{alias}") - initial_python_mass_flow = mass_flow.initial_python_value * 1.0e-3 - initial_amesim_mass_flow = mass_flow.initial_amesim_value * 1.0e-3 - final_python_mass_flow = mass_flow.final_python_value * 1.0e-3 - final_amesim_mass_flow = mass_flow.final_amesim_value * 1.0e-3 - return TestMqlFullStatePnvoDiagnostic( - alias=alias, - initial_time_s=opening.initial_time_s, - final_time_s=opening.final_time_s, - initial_python_opening=opening.initial_python_value, - initial_amesim_opening=opening.initial_amesim_value, - final_python_opening=opening.final_python_value, - final_amesim_opening=opening.final_amesim_value, - initial_python_mass_flow_kg_s=initial_python_mass_flow, - initial_amesim_mass_flow_kg_s=initial_amesim_mass_flow, - final_python_mass_flow_kg_s=final_python_mass_flow, - final_amesim_mass_flow_kg_s=final_amesim_mass_flow, - final_mass_flow_abs_error_kg_s=abs( - final_python_mass_flow - final_amesim_mass_flow - ), - ) - - def diagnostics_by_final_abs_error(self): - return tuple( - sorted( - ( - self.signal_diagnostic(data_path) - for data_path in self.output.data_paths - ), - key=lambda diagnostic: diagnostic.final_abs_error, - reverse=True, - ) - ) - - @property - def largest_final_abs_error_diagnostic(self): - diagnostics = self.diagnostics_by_final_abs_error() - return diagnostics[0] if diagnostics else None - - def chamber_rhs_diagnostic(self, chamber_alias: str, sample_index: int = -1): - state_vector = [row[sample_index] for row in self.result.y] - return self.closure.variable_chamber_rhs_diagnostic( - chamber_alias=chamber_alias, - state_vector=state_vector, - time_s=float(self.result.t[sample_index]), - ) - - -@dataclass(frozen=True) -class TestMqlPnvoEventBoundaryDiagnostic: - orifice_alias: str - event_time_s: float - integration_stop_time_s: float - data_paths: tuple[str, ...] - python_values_by_data_path: dict[str, float] - amesim_values_by_data_path: dict[str, float] - - def abs_error(self, data_path: str) -> float: - return abs( - self.python_values_by_data_path[data_path] - - self.amesim_values_by_data_path[data_path] - ) - - -@dataclass(frozen=True) -class TestMqlPnvoEventWindowSegmentDiagnostic: - t_start: float - t_stop: float - method: str - rtol: float - atol: float - max_step: float - rhs_evaluations: int - success: bool - message: str - - -@dataclass(frozen=True) -class TestMqlPnl0001PressureLossCalibrationDiagnostic: - line_alias: str - chamber_alias: str - time_s: float - amesim_cm: float - amesim_dm1_g_s: float - amesim_mass_flow_magnitude_kg_s: float - amesim_line_gauge_pressure_pa: float - amesim_chamber_gauge_pressure_pa: float - amesim_line_temperature_k: float - amesim_pressure_drop_pa: float - current_darcy_pressure_drop_pa: float - pressure_drop_multiplier: float - candidate_pn2pipefr_dm1_g_s: float - candidate_pn2pipefr_to_amesim_dm1_ratio: float - amesim_linear_conductance_kg_s_sqrt_k_per_pa: float - python_linear_conductance_kg_s_sqrt_k_per_pa: float - python_to_amesim_linear_conductance_ratio: float - - -@dataclass(frozen=True) -class TestMqlPnvoFlowParameterDiagnostic: - orifice_alias: str - time_s: float - amesim_cm: float - amesim_dm2_g_s: float - amesim_opening: float - amesim_gas_velocity_m_s: float - python_opening: float - python_flow_coefficient: float - python_effective_area_m2: float - python_line_pressure_pa: float - python_boundary_pressure_pa: float - python_upstream_pressure_pa: float - python_upstream_temperature_k: float - python_mass_flow_kg_s: float - python_cm: float - python_to_amesim_cm_ratio: float - - -@dataclass(frozen=True) -class TestMqlPnl0001EnergyFlowDiagnostic: - line_alias: str - chamber_alias: str - node_alias: str - time_s: float - amesim_port_1_enthalpy_flow_w: float - amesim_port_1_mass_flow_g_s: float - amesim_node_port_2_enthalpy_flow_w: float - amesim_node_port_2_mass_flow_g_s: float - amesim_observed_port_enthalpy_sum_w: float - python_port_1_internal_energy_flow_w: float - python_port_2_internal_energy_flow_w: float - python_current_energy_derivative_w: float - python_direct_enthalpy_port_1_flow_w: float - python_direct_enthalpy_port_2_flow_w: float - python_direct_enthalpy_energy_derivative_w: float - python_direct_minus_current_energy_derivative_w: float - python_p4_node_port_2_enthalpy_flow_w: float - python_p4_node_port_2_mass_flow_g_s: float - python_p4_node_port_2_connected_h_j_kg: float - python_p4_node_port_2_connected_temperature_k: float - python_p4_node_port_2_connected_h_delta_to_line_h_j_kg: float - python_p4_node_port_2_counterfactual_dtemp_k_s: float - reference_temperature_k: float - amesim_port_1_reference_enthalpy_estimate_w: float - amesim_port_1_reference_enthalpy_error_w: float - amesim_candidate_pn2pipefr_dm2i_g_s: float - amesim_candidate_pn2pipefr_dh2i_w: float - amesim_candidate_storage_enthalpy_sum_w: float - amesim_candidate_pn2pipefr_dtemp_k_s: float - amesim_candidate_pn2pipefr_dtemp_residual_k_s: float - python_reference_port_1_enthalpy_flow_w: float - python_reference_node_port_2_enthalpy_flow_w: float - python_reference_observed_port_enthalpy_sum_w: float - python_reference_port_1_to_amesim_error_w: float - python_reference_node_port_2_to_amesim_error_w: float - python_reference_sum_to_amesim_error_w: float - python_current_line_temperature_derivative_k_s: float - amesim_line_temperature_derivative_fd_k_s: float - amesim_pn2vol2_dtemp_node_plus_dh1_k_s: float - amesim_pn2vol2_dtemp_node_minus_dh1_k_s: float - python_reference_pn2vol2_dtemp_node_minus_dh1_k_s: float - - -@dataclass(frozen=True) -class TestMqlP4NodeEnthalpyBreakdownDiagnostic: - node_alias: str - time_s: float - amesim_port_1_enthalpy_flow_w: float - amesim_port_1_mass_flow_g_s: float - amesim_port_3_enthalpy_flow_w: float - amesim_port_3_mass_flow_g_s: float - amesim_port_4_enthalpy_flow_w: float - amesim_port_4_mass_flow_g_s: float - amesim_port_2_enthalpy_flow_w: float - amesim_port_2_mass_flow_g_s: float - python_port_1_enthalpy_flow_w: float - python_port_1_mass_flow_g_s: float - python_port_3_enthalpy_flow_w: float - python_port_3_mass_flow_g_s: float - python_port_4_enthalpy_flow_w: float - python_port_4_mass_flow_g_s: float - python_port_2_enthalpy_flow_w: float - python_port_2_mass_flow_g_s: float - python_reference_port_1_enthalpy_flow_w: float - python_reference_port_3_enthalpy_flow_w: float - python_reference_port_4_enthalpy_flow_w: float - python_reference_port_2_enthalpy_flow_w: float - python_real_gas_reference_port_1_enthalpy_flow_w: float - python_real_gas_reference_port_3_enthalpy_flow_w: float - python_real_gas_reference_port_4_enthalpy_flow_w: float - python_real_gas_reference_port_2_enthalpy_flow_w: float - python_port_2_enthalpy_error_w: float - python_reference_port_2_enthalpy_error_w: float - python_real_gas_reference_port_2_enthalpy_error_w: float - - -@dataclass(frozen=True) -class TestMqlPnvoUpstreamEnthalpyDiagnostic: - orifice_alias: str - upstream_line_alias: str - time_s: float - amesim_orifice_port_2_enthalpy_flow_w: float - amesim_orifice_port_2_mass_flow_g_s: float - amesim_orifice_port_3_enthalpy_flow_w: float - amesim_orifice_port_3_mass_flow_g_s: float - amesim_upstream_line_center_enthalpy_flow_w: float - amesim_upstream_line_center_mass_flow_g_s: float - amesim_upstream_line_port_2_temperature_k: float - amesim_upstream_line_port_2_pressure_pa: float - amesim_orifice_port_2_implied_reference_temperature_k: float - amesim_orifice_port_2_implied_temperature_delta_to_line_k: float - amesim_upstream_line_port_2_reference_enthalpy_flow_w: float - amesim_upstream_line_port_2_reference_enthalpy_error_w: float - amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w: float - amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w: float - python_orifice_to_node_flow_kg_s: float - python_upstream_line_port_2_temperature_k: float - python_upstream_line_port_2_pressure_pa: float - python_current_port_2_enthalpy_flow_w: float - python_reference_port_2_enthalpy_flow_w: float - python_real_gas_reference_port_2_enthalpy_flow_w: float - python_current_port_2_enthalpy_error_w: float - python_reference_port_2_enthalpy_error_w: float - python_real_gas_reference_port_2_enthalpy_error_w: float - -@dataclass(frozen=True) -class TestMqlPnl0003EnergyDiagnostic: - line_alias: str - time_s: float - amesim_port_1_temperature_k: float - amesim_port_2_temperature_k: float - amesim_port_1_pressure_pa: float - amesim_port_2_pressure_pa: float - amesim_port_1_mass_flow_g_s: float - amesim_port_1_enthalpy_flow_w: float - amesim_center_mass_flow_g_s: float - amesim_center_enthalpy_flow_w: float - amesim_port_2_mass_flow_g_s: float - amesim_port_2_enthalpy_flow_w: float - amesim_port_1_storage_mass_derivative_g_s: float - amesim_port_1_storage_enthalpy_sum_w: float - amesim_port_2_storage_mass_derivative_g_s: float - amesim_port_2_storage_enthalpy_sum_w: float - python_pn3_node_port_1_mass_flow_g_s: float - python_pn3_node_port_3_mass_flow_g_s: float - python_pn3_node_to_line_mass_flow_g_s: float - python_pn3_node_to_line_mass_flow_error_g_s: float - amesim_port_1_mass_estimate_g: float - amesim_port_2_mass_estimate_g: float - python_port_1_mass_g: float - python_port_2_mass_g: float - python_port_1_mass_error_to_amesim_g: float - python_port_2_mass_error_to_amesim_g: float - amesim_total_mass_derivative_fd_g_s: float - amesim_total_mass_derivative_backward_g_s: float - amesim_total_mass_derivative_forward_g_s: float - amesim_total_storage_mass_derivative_g_s: float - amesim_total_storage_mass_derivative_residual_g_s: float - amesim_center_flow_python_sign_equivalent_g_s: float - amesim_center_flow_python_sign_error_g_s: float - python_center_mass_flow_g_s: float - amesim_port_1_center_mass_derivative_g_s: float - amesim_port_2_center_mass_derivative_g_s: float - python_port_1_external_mass_flow_g_s: float - python_port_1_external_mass_flow_error_g_s: float - python_port_1_center_mass_derivative_g_s: float - python_port_1_center_mass_derivative_error_g_s: float - python_port_1_storage_mass_derivative_g_s: float - python_port_1_storage_mass_derivative_error_g_s: float - python_port_2_external_mass_flow_g_s: float - python_port_2_external_mass_flow_error_g_s: float - python_port_2_center_mass_derivative_g_s: float - python_port_2_center_mass_derivative_error_g_s: float - python_port_2_storage_mass_derivative_g_s: float - python_port_2_storage_mass_derivative_error_g_s: float - python_amesim_state_darcy_center_mass_flow_g_s: float - python_amesim_state_darcy_center_mass_flow_error_g_s: float - python_amesim_state_pn2pipefr_center_mass_flow_g_s: float - python_amesim_state_pn2pipefr_center_mass_flow_error_g_s: float - amesim_center_real_gas_reference_port_1_estimate_w: float - amesim_center_real_gas_reference_port_1_error_w: float - python_center_ideal_reference_port_1_estimate_w: float - python_center_ideal_reference_port_1_error_w: float - python_center_ideal_reference_port_2_estimate_w: float - python_center_ideal_reference_port_2_error_w: float - python_center_real_gas_reference_port_1_estimate_w: float - python_center_real_gas_reference_port_1_error_w: float - python_center_real_gas_reference_port_2_estimate_w: float - python_center_real_gas_reference_port_2_error_w: float - python_total_mass_derivative_kg_s: float - python_port_1_reference_external_enthalpy_flow_w: float - python_port_1_reference_center_enthalpy_flow_w: float - python_port_1_reference_storage_enthalpy_sum_w: float - python_port_1_reference_storage_enthalpy_error_w: float - python_port_2_reference_external_enthalpy_flow_w: float - python_port_2_reference_center_enthalpy_flow_w: float - python_port_2_reference_storage_enthalpy_sum_w: float - python_port_2_reference_storage_enthalpy_error_w: float - amesim_port_1_external_enthalpy_dtemp_component_k_s: float - amesim_port_1_center_enthalpy_dtemp_component_k_s: float - amesim_port_1_mass_offset_dtemp_component_k_s: float - amesim_port_1_heat_dtemp_component_k_s: float - python_port_1_external_enthalpy_dtemp_component_k_s: float - python_port_1_center_enthalpy_dtemp_component_k_s: float - python_port_1_mass_offset_dtemp_component_k_s: float - python_port_1_heat_dtemp_component_k_s: float - amesim_port_2_external_enthalpy_dtemp_component_k_s: float - amesim_port_2_center_enthalpy_dtemp_component_k_s: float - amesim_port_2_mass_offset_dtemp_component_k_s: float - amesim_port_2_heat_dtemp_component_k_s: float - python_port_2_external_enthalpy_dtemp_component_k_s: float - python_port_2_center_enthalpy_dtemp_component_k_s: float - python_port_2_mass_offset_dtemp_component_k_s: float - python_port_2_heat_dtemp_component_k_s: float - amesim_port_1_temperature_derivative_fd_k_s: float - amesim_port_1_temperature_derivative_backward_k_s: float - amesim_port_1_temperature_derivative_forward_k_s: float - amesim_port_2_temperature_derivative_fd_k_s: float - amesim_port_2_temperature_derivative_backward_k_s: float - amesim_port_2_temperature_derivative_forward_k_s: float - amesim_port_1_pn2vol_dtemp_k_s: float - amesim_port_2_pn2vol_dtemp_k_s: float - amesim_port_1_pn2vol_dtemp_residual_k_s: float - amesim_port_2_pn2vol_dtemp_residual_k_s: float - amesim_port_1_pressure_derivative_fd_pa_s: float - amesim_port_2_pressure_derivative_fd_pa_s: float - amesim_port_1_pressure_derivative_mass_component_pa_s: float - amesim_port_1_pressure_derivative_temperature_component_pa_s: float - amesim_port_1_pressure_derivative_eos_sum_pa_s: float - amesim_port_1_pressure_derivative_eos_residual_pa_s: float - amesim_port_2_pressure_derivative_mass_component_pa_s: float - amesim_port_2_pressure_derivative_temperature_component_pa_s: float - amesim_port_2_pressure_derivative_eos_sum_pa_s: float - amesim_port_2_pressure_derivative_eos_residual_pa_s: float - python_port_1_pressure_derivative_mass_component_pa_s: float - python_port_1_pressure_derivative_temperature_component_pa_s: float - python_port_1_pressure_derivative_eos_sum_pa_s: float - python_port_2_pressure_derivative_mass_component_pa_s: float - python_port_2_pressure_derivative_temperature_component_pa_s: float - python_port_2_pressure_derivative_eos_sum_pa_s: float - python_port_1_temperature_k: float - python_port_2_temperature_k: float - python_port_1_pressure_pa: float - python_port_2_pressure_pa: float - python_port_1_mass_derivative_kg_s: float - python_port_2_mass_derivative_kg_s: float - python_port_1_current_dtemp_k_s: float - python_port_2_current_dtemp_k_s: float - python_port_1_real_gas_reference_dtemp_k_s: float - python_port_2_real_gas_reference_dtemp_k_s: float - python_port_2_amesim_center_counterfactual_dtemp_k_s: float - python_port_2_amesim_external_counterfactual_dtemp_k_s: float - python_port_2_amesim_external_center_counterfactual_dtemp_k_s: float - python_port_2_temperature_error_to_amesim_k: float - - -@dataclass(frozen=True) -class TestMqlPnch012EnergyEquationDiagnostic: - chamber_alias: str - piston_alias: str - line_alias: str - time_s: float - amesim_port_1_enthalpy_flow_w: float - amesim_port_1_mass_flow_g_s: float - amesim_chamber_mass_derivative_fd_g_s: float - amesim_chamber_mass_derivative_residual_g_s: float - amesim_chamber_temperature_derivative_fd_k_s: float - amesim_chamber_volume_rate_fd_m3_s: float - amesim_piston_volume_rate_m3_s: float - amesim_heat_flow_w: float - amesim_boundary_work_fd_volume_w: float - amesim_pn2vol_reference_dtemp_fd_volume_k_s: float - amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s: float - amesim_pn2vol_reference_dtemp_piston_volume_k_s: float - amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s: float - python_port_a_mass_flow_kg_s: float - python_current_energy_derivative_w: float - python_current_chamber_temperature_derivative_k_s: float - reference_temperature_k: float - - -@dataclass(frozen=True) -class TestMqlPneumatic96InletLineDiagnostic: - line_alias: str - node_alias: str - orifice_alias: str - chamber_alias: str - time_s: float - amesim_line_pressure_pa: float - amesim_line_temperature_k: float - amesim_line_mass_g: float - amesim_line_mass_derivative_fd_g_s: float - amesim_line_to_node_mass_flow_g_s: float - amesim_orifice_to_line_mass_flow_g_s: float - amesim_storage_mass_derivative_from_ports_g_s: float - amesim_storage_mass_derivative_residual_g_s: float - amesim_chamber_pressure_pa: float - amesim_chamber_temperature_k: float - amesim_node_pressure_pa: float - amesim_node_temperature_k: float - amesim_line_temperature_derivative_fd_k_s: float - amesim_line_pressure_derivative_fd_pa_s: float - amesim_port_1_enthalpy_dtemp_component_k_s: float - amesim_port_2_enthalpy_dtemp_component_k_s: float - amesim_mass_offset_dtemp_component_k_s: float - amesim_reference_temperature_derivative_sum_k_s: float - amesim_reference_temperature_derivative_residual_k_s: float - amesim_pressure_derivative_mass_component_pa_s: float - amesim_pressure_derivative_temperature_component_pa_s: float - amesim_pressure_derivative_eos_sum_pa_s: float - amesim_pressure_derivative_eos_residual_pa_s: float - python_line_pressure_pa: float - python_line_temperature_k: float - python_line_mass_g: float - python_line_pressure_error_pa: float - python_line_temperature_error_k: float - python_line_mass_error_g: float - python_line_to_node_mass_flow_g_s: float - python_line_to_node_mass_flow_error_g_s: float - python_orifice_to_line_mass_flow_g_s: float - python_orifice_to_line_mass_flow_error_g_s: float - python_storage_mass_derivative_g_s: float - python_storage_mass_derivative_error_g_s: float - python_line_to_node_amesim_node_mass_flow_g_s: float - python_line_to_node_amesim_node_mass_flow_error_g_s: float - python_line_to_node_amesim_line_mass_flow_g_s: float - python_line_to_node_amesim_line_mass_flow_error_g_s: float - python_line_to_node_amesim_node_line_mass_flow_g_s: float - python_line_to_node_amesim_node_line_mass_flow_error_g_s: float - python_orifice_to_line_amesim_line_mass_flow_g_s: float - python_orifice_to_line_amesim_line_mass_flow_error_g_s: float - python_orifice_to_line_amesim_chamber_mass_flow_g_s: float - python_orifice_to_line_amesim_chamber_mass_flow_error_g_s: float - python_orifice_to_line_amesim_line_chamber_mass_flow_g_s: float - python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s: float - python_storage_amesim_boundary_states_mass_derivative_g_s: float - python_storage_amesim_boundary_states_mass_derivative_error_g_s: float - python_chamber_pressure_pa: float - python_chamber_temperature_k: float - python_current_temperature_derivative_k_s: float - python_port_1_current_dtemp_component_k_s: float - python_port_2_current_dtemp_component_k_s: float - python_heat_dtemp_component_k_s: float - python_temperature_derivative_error_k_s: float - python_pressure_derivative_mass_component_pa_s: float - python_pressure_derivative_temperature_component_pa_s: float - python_pressure_derivative_eos_sum_pa_s: float - python_pressure_derivative_eos_error_pa_s: float - python_reference_port_1_enthalpy_flow_w: float - python_reference_port_2_enthalpy_flow_w: float - python_reference_port_1_dtemp_component_k_s: float - python_reference_port_2_dtemp_component_k_s: float - python_reference_mass_offset_dtemp_component_k_s: float - python_reference_temperature_derivative_k_s: float - python_reference_temperature_derivative_error_k_s: float - python_reference_pressure_derivative_mass_component_pa_s: float - python_reference_pressure_derivative_temperature_component_pa_s: float - python_reference_pressure_derivative_eos_sum_pa_s: float - python_reference_pressure_derivative_eos_error_pa_s: float - python_reference_amesim_storage_mass_offset_dtemp_component_k_s: float - python_reference_amesim_storage_temperature_derivative_k_s: float - python_reference_amesim_storage_temperature_derivative_error_k_s: float - python_reference_amesim_storage_pressure_derivative_mass_component_pa_s: float - python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s: float - python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s: float - python_reference_amesim_storage_pressure_derivative_eos_error_pa_s: float - python_active_temperature_derivative_k_s: float - python_active_temperature_derivative_error_k_s: float - python_active_pressure_derivative_eos_sum_pa_s: float - python_active_pressure_derivative_eos_error_pa_s: float - python_chamber_pressure_error_pa: float - python_chamber_temperature_error_k: float - - -@dataclass(frozen=True) -class TestMqlPnvoEventWindowSampleDiagnostic: - time_s: float - data_paths: tuple[str, ...] - python_values_by_data_path: dict[str, float] - amesim_values_by_data_path: dict[str, float] - amesim_sample_left_time_s: float - amesim_sample_right_time_s: float - pnl0001_rhs_diagnostics: tuple[TestMqlPnl0001LineRhsDiagnostic, ...] = field( - default_factory=tuple - ) - pnl0001_pressure_loss_diagnostics: tuple[ - TestMqlPnl0001PressureLossCalibrationDiagnostic, ... - ] = field(default_factory=tuple) - pnvo_flow_parameter_diagnostics: tuple[ - TestMqlPnvoFlowParameterDiagnostic, ... - ] = field(default_factory=tuple) - pnl0001_energy_flow_diagnostics: tuple[ - TestMqlPnl0001EnergyFlowDiagnostic, ... - ] = field(default_factory=tuple) - p4_node_enthalpy_breakdown_diagnostics: tuple[ - TestMqlP4NodeEnthalpyBreakdownDiagnostic, ... - ] = field(default_factory=tuple) - pnvo_upstream_enthalpy_diagnostics: tuple[ - TestMqlPnvoUpstreamEnthalpyDiagnostic, ... - ] = field(default_factory=tuple) - pnl0003_energy_diagnostics: tuple[ - TestMqlPnl0003EnergyDiagnostic, ... - ] = field(default_factory=tuple) - pneumatic_96_inlet_line_diagnostics: tuple[ - TestMqlPneumatic96InletLineDiagnostic, ... - ] = field(default_factory=tuple) - pnch012_energy_equation_diagnostics: tuple[ - TestMqlPnch012EnergyEquationDiagnostic, ... - ] = field(default_factory=tuple) - - @property - def amesim_values_are_interpolated(self) -> bool: - return self.amesim_sample_left_time_s != self.amesim_sample_right_time_s - - def abs_error(self, data_path: str) -> float: - return abs( - self.python_values_by_data_path[data_path] - - self.amesim_values_by_data_path[data_path] - ) - - -@dataclass(frozen=True) -class TestMqlPnvoEventWindowDiagnostic: - orifice_alias: str - event_time_s: float - final_time_s: float - data_paths: tuple[str, ...] - segment_diagnostics: tuple[TestMqlPnvoEventWindowSegmentDiagnostic, ...] - sample_diagnostics: tuple[TestMqlPnvoEventWindowSampleDiagnostic, ...] - python_values_by_data_path: dict[str, float] - amesim_values_by_data_path: dict[str, float] - - def abs_error(self, data_path: str) -> float: - return abs( - self.python_values_by_data_path[data_path] - - self.amesim_values_by_data_path[data_path] - ) - - -@dataclass(frozen=True) -class TestMqlPnvoEventWindowCandidateMetricComparison: - time_s: float - metric_name: str - default_abs_error: float - candidate_abs_error: float - - @property - def abs_error_delta(self) -> float: - return self.candidate_abs_error - self.default_abs_error - - -@dataclass(frozen=True) -class TestMqlPnvoEventWindowCandidateComparisonDiagnostic: - orifice_alias: str - event_time_s: float - final_time_s: float - default_diagnostic: TestMqlPnvoEventWindowDiagnostic - candidate_diagnostic: TestMqlPnvoEventWindowDiagnostic - metric_comparisons: tuple[ - TestMqlPnvoEventWindowCandidateMetricComparison, ... - ] - - -@dataclass(frozen=True) -class TestMqlFullStateComparisonPathConfig: - archive_path: Path = field( - default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH - ) - amesim_results_archive_path: Path | None = None - output_dir: Path | None = None - - @property - def resolved_amesim_results_archive_path(self) -> Path: - return self.amesim_results_archive_path or self.archive_path - - -@dataclass(frozen=True) -class TestMqlFullStateComparisonExecutionConfig: - write_summary: bool = True - write_comparison_csv: bool = True - data_paths: tuple[str, ...] | None = DEFAULT_FULL_STATE_COMPARISON_DATA_PATHS - solver: SolveIVPConfig = field( - default_factory=lambda: SolveIVPConfig(t_stop=1.0e-2, max_step=1.0e-3) - ) - t_eval: tuple[float, ...] | None = (0.0, 1.0e-2) - inlet_node_pressure_pa: float = 15.31e6 - resistance_boundary_pressure_pa: float = 15.29e6 - inlet_node_temperature_k: float = 293.15 - resistance_boundary_temperature_k: float = 293.15 - use_pneumatic_96_reference_rhs: bool = False - use_pneumatic_69_reference_rhs: bool = False - - -@dataclass(frozen=True) -class TestMqlFullStateComparisonScriptConfig: - paths: TestMqlFullStateComparisonPathConfig = field( - default_factory=TestMqlFullStateComparisonPathConfig - ) - execution: TestMqlFullStateComparisonExecutionConfig = field( - default_factory=TestMqlFullStateComparisonExecutionConfig - ) - - -def _default_output_dir() -> Path: - timestamp = datetime.now(UTC).strftime("test_mql_full_state_%Y%m%d_%H%M%S_%f") - return SIMULATION_RUNS_DIR / timestamp - - -def run_test_mql_full_state_comparison( - config: TestMqlFullStateComparisonScriptConfig | None = None, -) -> tuple[TestMqlFullStateComparisonRun, Path]: - config = config or TestMqlFullStateComparisonScriptConfig() - system = TestMqlSystem(archive_path=config.paths.archive_path) - amesim_results = load_test_mql_amesim_results( - config.paths.resolved_amesim_results_archive_path, - time_stop_s=config.execution.solver.t_stop, - ) - output_schema = build_test_mql_output_schema(amesim_results) - selected_paths = config.execution.data_paths - spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] - result = system.simulate_full_state_series_from_spec( - spec, - inlet_node_pressure_pa=config.execution.inlet_node_pressure_pa, - resistance_boundary_pressure_pa=( - config.execution.resistance_boundary_pressure_pa - ), - inlet_node_temperature_k=config.execution.inlet_node_temperature_k, - resistance_boundary_temperature_k=( - config.execution.resistance_boundary_temperature_k - ), - use_pneumatic_96_reference_rhs=( - config.execution.use_pneumatic_96_reference_rhs - ), - use_pneumatic_69_reference_rhs=( - config.execution.use_pneumatic_69_reference_rhs - ), - config=config.execution.solver, - t_eval=list(config.execution.t_eval) if config.execution.t_eval is not None else None, - data_paths=selected_paths, - ) - closure = system.full_state_closure_from_spec( - spec, - inlet_node_pressure_pa=config.execution.inlet_node_pressure_pa, - resistance_boundary_pressure_pa=( - config.execution.resistance_boundary_pressure_pa - ), - inlet_node_temperature_k=config.execution.inlet_node_temperature_k, - resistance_boundary_temperature_k=( - config.execution.resistance_boundary_temperature_k - ), - use_pneumatic_96_reference_rhs=( - config.execution.use_pneumatic_96_reference_rhs - ), - use_pneumatic_69_reference_rhs=( - config.execution.use_pneumatic_69_reference_rhs - ), - ) - series_by_data_path = { - data_path: result.series[data_path] - for data_path in result.series - if data_path != "time" - } - output = validate_test_mql_output( - times=result.t, - series_by_data_path=series_by_data_path, - schema=output_schema, - data_paths=selected_paths, - ) - comparison = compare_validated_test_mql_output( - times=output.times, - series_by_data_path=output.series_by_data_path, - schema=output_schema, - amesim_results=amesim_results, - data_paths=output.data_paths, - ) - run = TestMqlFullStateComparisonRun( - system=system, - closure=closure, - amesim_results=amesim_results, - output_schema=output_schema, - result=result, - output=output, - comparison=comparison, - ) - output_dir = config.paths.output_dir or _default_output_dir() - if config.execution.write_summary or config.execution.write_comparison_csv: - output_dir.mkdir(parents=True, exist_ok=True) - if config.execution.write_summary: - (output_dir / "test_mql_full_state_comparison_summary.txt").write_text( - format_test_mql_full_state_comparison_summary(run), - encoding="utf-8", - ) - if config.execution.write_comparison_csv: - write_test_mql_comparison_csv( - output_dir=output_dir, - python_times=output.times, - python_series_by_data_path=output.series_by_data_path, - amesim_results=amesim_results, - data_paths=output.data_paths, - ) - return run, output_dir - - -def run_test_mql_pnvo_event_boundary_diagnostic( - config: TestMqlFullStateComparisonScriptConfig | None = None, - *, - orifice_alias: str = "pn_morifice_1", -) -> TestMqlPnvoEventBoundaryDiagnostic: - config = config or TestMqlFullStateComparisonScriptConfig() - system = TestMqlSystem(archive_path=config.paths.archive_path) - control = system.pneumatic_assembly.variable_orifice_controls[orifice_alias] - event_time_s = control.step.step_time_s - integration_stop_time_s = nextafter(event_time_s, 0.0) - solver_template = config.execution.solver - solver = SolveIVPConfig( - t_start=solver_template.t_start, - t_stop=integration_stop_time_s, - method=solver_template.method, - rtol=solver_template.rtol, - atol=solver_template.atol, - max_step=solver_template.max_step, - ) - spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] - closure_kwargs = { - "inlet_node_pressure_pa": config.execution.inlet_node_pressure_pa, - "resistance_boundary_pressure_pa": ( - config.execution.resistance_boundary_pressure_pa - ), - "inlet_node_temperature_k": config.execution.inlet_node_temperature_k, - "resistance_boundary_temperature_k": ( - config.execution.resistance_boundary_temperature_k - ), - "use_pneumatic_96_reference_rhs": ( - config.execution.use_pneumatic_96_reference_rhs - ), - "use_pneumatic_69_reference_rhs": ( - config.execution.use_pneumatic_69_reference_rhs - ), - } - solution = system.simulate_full_state_from_spec( - spec, - config=solver, - t_eval=[solver.t_start, integration_stop_time_s], - **closure_kwargs, - ) - state_vector = [row[-1] for row in solution.y] - closure = system.full_state_closure_from_spec(spec, **closure_kwargs) - data_paths = ( - "press@pn_c1_8", - "dm1@pneumatic_69", - f"xv@{orifice_alias}", - f"dm2@{orifice_alias}", - ) - python_values = closure.data_path_values( - time_s=event_time_s, - state_vector=state_vector, - data_paths=data_paths, - ) - amesim_results = load_test_mql_amesim_results( - config.paths.resolved_amesim_results_archive_path, - time_stop_s=event_time_s, - ) - amesim_values = { - data_path: interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - event_time_s, - ) - for data_path in data_paths - } - return TestMqlPnvoEventBoundaryDiagnostic( - orifice_alias=orifice_alias, - event_time_s=event_time_s, - integration_stop_time_s=integration_stop_time_s, - data_paths=data_paths, - python_values_by_data_path=python_values, - amesim_values_by_data_path=amesim_values, - ) - - -def format_test_mql_pnvo_event_boundary_summary( - diagnostic: TestMqlPnvoEventBoundaryDiagnostic, -) -> str: - lines = [ - "Model: test_mql", - f"Mode: PNVO event boundary diagnostic ({diagnostic.orifice_alias})", - f"Event time: {diagnostic.event_time_s}", - f"Integrated left limit: {diagnostic.integration_stop_time_s}", - "Observation side: right-continuous STEP0 opening", - ] - for data_path in diagnostic.data_paths: - lines.append( - f" - {data_path}: " - f"python={diagnostic.python_values_by_data_path[data_path]}, " - f"amesim={diagnostic.amesim_values_by_data_path[data_path]}, " - f"abs_error={diagnostic.abs_error(data_path)}" - ) - return "\n".join(lines) + "\n" - - -def _pnl0001_pressure_loss_calibration_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - python_values_by_data_path: dict[str, float], - line_alias: str, - chamber_alias: str, - time_s: float, -) -> TestMqlPnl0001PressureLossCalibrationDiagnostic: - if line_alias != "pneumatic_69" or chamber_alias != "pn_c1_8": - raise KeyError(f"Unsupported PNL0001 pressure-loss diagnostic: {line_alias}") - line = closure.pneumatic_closure.components.p4_port3_remote_primary_line - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_cm = amesim_value(f"cm@{line_alias}") - amesim_dm1_g_s = amesim_value(f"dm1@{line_alias}") - amesim_line_gauge_pressure_pa = amesim_value(f"p2@{line_alias}") - amesim_chamber_gauge_pressure_pa = amesim_value(f"press@{chamber_alias}") - amesim_line_temperature_k = amesim_value(f"t2@{line_alias}") - amesim_chamber_temperature_k = amesim_value(f"temp@{chamber_alias}") - mass_flow_magnitude_kg_s = abs(amesim_dm1_g_s) * 1.0e-3 - amesim_pressure_drop_pa = abs( - amesim_line_gauge_pressure_pa - amesim_chamber_gauge_pressure_pa - ) - current_darcy_pressure_drop_pa = abs( - line.darcy_pressure_drop_for_state( - mass_flow_kg_s=mass_flow_magnitude_kg_s, - pressure_pa=( - amesim_line_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA - ), - temperature_k=amesim_line_temperature_k, - ) - ) - if current_darcy_pressure_drop_pa > 0.0: - pressure_drop_multiplier = ( - amesim_pressure_drop_pa / current_darcy_pressure_drop_pa - ) - else: - pressure_drop_multiplier = ( - float("inf") if amesim_pressure_drop_pa > 0.0 else 1.0 - ) - candidate_port1_to_port2_kg_s = line.pn2pipefr_mass_flow( - port_1_pressure_pa=( - amesim_chamber_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA - ), - port_1_temperature_k=amesim_chamber_temperature_k, - port_2_pressure_pa=( - amesim_line_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA - ), - port_2_temperature_k=amesim_line_temperature_k, - ) - candidate_pn2pipefr_dm1_g_s = -candidate_port1_to_port2_kg_s * 1.0e3 - if amesim_dm1_g_s != 0.0: - candidate_pn2pipefr_to_amesim_dm1_ratio = ( - candidate_pn2pipefr_dm1_g_s / amesim_dm1_g_s - ) - else: - candidate_pn2pipefr_to_amesim_dm1_ratio = ( - float("inf") if candidate_pn2pipefr_dm1_g_s != 0.0 else 1.0 - ) - amesim_linear_conductance = _pnl0001_linear_conductance( - dm1_g_s=amesim_dm1_g_s, - temperature_k=amesim_line_temperature_k, - pressure_drop_pa=amesim_pressure_drop_pa, - ) - python_pressure_drop_pa = abs( - python_values_by_data_path[f"p2@{line_alias}"] - - python_values_by_data_path[f"press@{chamber_alias}"] - ) - python_linear_conductance = _pnl0001_linear_conductance( - dm1_g_s=python_values_by_data_path[f"dm1@{line_alias}"], - temperature_k=python_values_by_data_path[f"t2@{line_alias}"], - pressure_drop_pa=python_pressure_drop_pa, - ) - if amesim_linear_conductance > 0.0: - conductance_ratio = python_linear_conductance / amesim_linear_conductance - else: - conductance_ratio = ( - float("inf") if python_linear_conductance > 0.0 else 1.0 - ) - return TestMqlPnl0001PressureLossCalibrationDiagnostic( - line_alias=line_alias, - chamber_alias=chamber_alias, - time_s=time_s, - amesim_cm=amesim_cm, - amesim_dm1_g_s=amesim_dm1_g_s, - amesim_mass_flow_magnitude_kg_s=mass_flow_magnitude_kg_s, - amesim_line_gauge_pressure_pa=amesim_line_gauge_pressure_pa, - amesim_chamber_gauge_pressure_pa=amesim_chamber_gauge_pressure_pa, - amesim_line_temperature_k=amesim_line_temperature_k, - amesim_pressure_drop_pa=amesim_pressure_drop_pa, - current_darcy_pressure_drop_pa=current_darcy_pressure_drop_pa, - pressure_drop_multiplier=pressure_drop_multiplier, - candidate_pn2pipefr_dm1_g_s=candidate_pn2pipefr_dm1_g_s, - candidate_pn2pipefr_to_amesim_dm1_ratio=( - candidate_pn2pipefr_to_amesim_dm1_ratio - ), - amesim_linear_conductance_kg_s_sqrt_k_per_pa=( - amesim_linear_conductance - ), - python_linear_conductance_kg_s_sqrt_k_per_pa=( - python_linear_conductance - ), - python_to_amesim_linear_conductance_ratio=conductance_ratio, - ) - - -def _pnl0001_linear_conductance( - *, - dm1_g_s: float, - temperature_k: float, - pressure_drop_pa: float, -) -> float: - if temperature_k <= 0.0: - raise ValueError("temperature_k must be positive") - if pressure_drop_pa <= 0.0: - return 0.0 - return abs(dm1_g_s) * 1.0e-3 * sqrt(temperature_k) / pressure_drop_pa - - -def _ideal_pn2vol_reference_dtemp( - *, - gas: AmesimPneumaticGas, - mass_kg: float, - temperature_k: float, - mass_derivative_kg_s: float, - enthalpy_flow_w: float, - heat_flow_w: float, - reference_temperature_k: float = 298.15, -) -> float: - reference_offset_flow_w = ( - gas.cp * reference_temperature_k - gas.cv * temperature_k - ) * mass_derivative_kg_s - return ( - enthalpy_flow_w + reference_offset_flow_w + heat_flow_w - ) / (mass_kg * gas.cv) - - -def _series_finite_difference_at( - *, - times: tuple[float, ...] | list[float], - values: tuple[float, ...] | list[float], - time_s: float, -) -> float: - if len(times) != len(values): - raise ValueError("times and values must have equal length") - if len(times) < 2: - raise ValueError("at least two samples are required") - index = min(range(len(times)), key=lambda idx: abs(times[idx] - time_s)) - if index == 0: - left = 0 - right = 1 - elif index == len(times) - 1: - left = len(times) - 2 - right = len(times) - 1 - else: - left = index - 1 - right = index + 1 - dt = times[right] - times[left] - if dt == 0.0: - raise ValueError("finite difference time interval must be non-zero") - return (values[right] - values[left]) / dt - - -def _series_one_sided_differences_at( - *, - times: tuple[float, ...] | list[float], - values: tuple[float, ...] | list[float], - time_s: float, -) -> tuple[float, float]: - if len(times) != len(values): - raise ValueError("times and values must have equal length") - if len(times) < 2: - raise ValueError("at least two samples are required") - index = min(range(len(times)), key=lambda idx: abs(times[idx] - time_s)) - left = max(index - 1, 0) - right = min(index + 1, len(times) - 1) - backward_dt = times[index] - times[left] - forward_dt = times[right] - times[index] - if backward_dt == 0.0: - backward = (values[right] - values[index]) / forward_dt - else: - backward = (values[index] - values[left]) / backward_dt - if forward_dt == 0.0: - forward = (values[index] - values[left]) / backward_dt - else: - forward = (values[right] - values[index]) / forward_dt - return backward, forward - - -def _series_sample_bracket( - *, - times: tuple[float, ...] | list[float], - time_s: float, -) -> tuple[float, float]: - if not times: - raise ValueError("at least one sample time is required") - index = bisect_left(times, time_s) - tolerance = 1.0e-12 * max(1.0, abs(time_s)) - for candidate_index in (index - 1, index): - if 0 <= candidate_index < len(times): - candidate_time = times[candidate_index] - if abs(candidate_time - time_s) <= tolerance: - return candidate_time, candidate_time - if index <= 0: - return times[0], times[0] - if index >= len(times): - return times[-1], times[-1] - return times[index - 1], times[index] - - -def _reference_enthalpy_flow_for_node_port( - *, - gas: AmesimPneumaticGas, - reference_temperature_k: float, - flow_kg_s: float, - node_temperature_k: float, - connected_temperature_k: float, - port_mass_flow_kg_s: float, -) -> float: - stream_temperature_k = ( - node_temperature_k if flow_kg_s >= 0.0 else connected_temperature_k - ) - reference_h = gas.specific_reference_enthalpy( - stream_temperature_k, - reference_temperature_k, - ) - return port_mass_flow_kg_s * reference_h - - -def _real_gas_reference_enthalpy_flow_for_node_port( - *, - gas: AmesimPneumaticGas, - reference_temperature_k: float, - flow_kg_s: float, - node_pressure_pa: float, - node_temperature_k: float, - connected_pressure_pa: float, - connected_temperature_k: float, - port_mass_flow_kg_s: float, -) -> float: - stream_pressure_pa = node_pressure_pa if flow_kg_s >= 0.0 else connected_pressure_pa - stream_temperature_k = ( - node_temperature_k if flow_kg_s >= 0.0 else connected_temperature_k - ) - reference_h = gas.pressure_reference_enthalpy( - stream_pressure_pa, - stream_temperature_k, - reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, - reference_temperature=reference_temperature_k, - ) - return port_mass_flow_kg_s * reference_h - - -def _pnl0001_energy_flow_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - state_vector: list[float], - rhs_diagnostic: TestMqlPnl0001LineRhsDiagnostic, - line_alias: str, - chamber_alias: str, - node_alias: str, - time_s: float, -) -> TestMqlPnl0001EnergyFlowDiagnostic: - if line_alias != "pneumatic_69" or chamber_alias != "pn_c1_8": - raise KeyError(f"Unsupported PNL0001 energy diagnostic: {line_alias}") - if node_alias != "pnnode4_16": - raise KeyError(f"Unsupported PNL0001 node diagnostic: {node_alias}") - - snapshot = closure.snapshot_at(time_s, state_vector).pneumatic - line = closure.pneumatic_closure.components.p4_port3_remote_primary_line - line_properties = snapshot.p4_port3_remote_primary_line - chamber_properties = snapshot.p4_port3_remote_primary_chamber - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_dh1 = amesim_value(f"dh1@{line_alias}") - amesim_dm1 = amesim_value(f"dm1@{line_alias}") - amesim_node_dh2 = amesim_value(f"dh2@{node_alias}") - amesim_node_dm2 = amesim_value(f"dm2@{node_alias}") - direct_port_1 = rhs_diagnostic.chamber_to_line_flow_kg_s * ( - chamber_properties.h - if rhs_diagnostic.chamber_to_line_flow_kg_s > 0.0 - else line_properties.h - ) - direct_port_2 = rhs_diagnostic.node_to_line_flow_kg_s * line_properties.h - direct_energy_derivative = ( - direct_port_1 + direct_port_2 + rhs_diagnostic.thermal_energy_flow_w - ) - p4_node_port_2_mass_flow_kg_s = ( - snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - if abs(p4_node_port_2_mass_flow_kg_s) <= 1.0e-12: - p4_node_port_2_connected_h = line_properties.h - else: - p4_node_port_2_connected_h = ( - snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w - / p4_node_port_2_mass_flow_kg_s - ) - p4_node_port_2_inlet_h = ( - p4_node_port_2_connected_h - if rhs_diagnostic.node_to_line_flow_kg_s > 0.0 - else line_properties.h - ) - p4_node_port_2_counterfactual_energy_derivative = ( - rhs_diagnostic.port_1_energy_flow_w - + rhs_diagnostic.node_to_line_flow_kg_s * p4_node_port_2_inlet_h - + rhs_diagnostic.thermal_energy_flow_w - ) - p4_node_port_2_counterfactual_dtemp = ( - p4_node_port_2_counterfactual_energy_derivative - - line_properties.u * rhs_diagnostic.mass_derivative_kg_s - ) / (line.state.m * line.gas.cv) - reference_temperature_k = 298.15 - - def reference_h(temperature_k: float) -> float: - return line.gas.specific_reference_enthalpy( - temperature_k, - reference_temperature_k, - ) - - amesim_stream_temperature = ( - amesim_value(f"t2@{line_alias}") - if amesim_dm1 >= 0.0 - else amesim_value(f"temp@{chamber_alias}") - ) - reference_enthalpy_estimate = ( - amesim_dm1 * 1.0e-3 * reference_h(amesim_stream_temperature) - ) - python_dm1_kg_s = -rhs_diagnostic.chamber_to_line_flow_kg_s - python_port_1_stream_temperature_k = ( - line_properties.T if python_dm1_kg_s >= 0.0 else chamber_properties.T - ) - python_reference_port_1 = python_dm1_kg_s * reference_h( - python_port_1_stream_temperature_k - ) - python_reference_node_port_2 = sum( - ( - _reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, - node_temperature_k=snapshot.p4_port3_remote_primary_line.T, - connected_temperature_k=snapshot.p4_port3_line.T, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, - ), - _reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, - node_temperature_k=snapshot.p4_port3_remote_primary_line.T, - connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, - ), - _reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, - connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, - port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - ), - ) - ) - python_reference_sum = python_reference_port_1 + python_reference_node_port_2 - - amesim_mgas_kg = amesim_value(f"mgas@{line_alias}") * 1.0e-3 - amesim_line_temperature = amesim_value(f"t2@{line_alias}") - amesim_mass_derivative = (amesim_node_dm2 - amesim_dm1) * 1.0e-3 - amesim_heat_flow = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * (line.external_temperature - amesim_line_temperature) - ) - current_line_dtemp = ( - rhs_diagnostic.energy_derivative_w - - line_properties.u * rhs_diagnostic.mass_derivative_kg_s - ) / (line.state.m * line.gas.cv) - amesim_line_dtemp_fd = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_results.series(f"t2@{line_alias}"), - time_s=time_s, - ) - candidate_pn2pipefr_dm2i_g_s = -amesim_dm1 - candidate_pn2pipefr_dh2i = -reference_enthalpy_estimate - candidate_storage_sdh = amesim_node_dh2 + candidate_pn2pipefr_dh2i - candidate_pn2pipefr_dtemp = _ideal_pn2vol_reference_dtemp( - gas=line.gas, - mass_kg=amesim_mgas_kg, - temperature_k=amesim_line_temperature, - mass_derivative_kg_s=amesim_mass_derivative, - enthalpy_flow_w=candidate_storage_sdh, - heat_flow_w=amesim_heat_flow, - ) - amesim_node_plus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( - gas=line.gas, - mass_kg=amesim_mgas_kg, - temperature_k=amesim_line_temperature, - mass_derivative_kg_s=amesim_mass_derivative, - enthalpy_flow_w=amesim_node_dh2 + amesim_dh1, - heat_flow_w=amesim_heat_flow, - ) - amesim_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( - gas=line.gas, - mass_kg=amesim_mgas_kg, - temperature_k=amesim_line_temperature, - mass_derivative_kg_s=amesim_mass_derivative, - enthalpy_flow_w=amesim_node_dh2 - amesim_dh1, - heat_flow_w=amesim_heat_flow, - ) - python_reference_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp( - gas=line.gas, - mass_kg=line.state.m, - temperature_k=line_properties.T, - mass_derivative_kg_s=rhs_diagnostic.mass_derivative_kg_s, - enthalpy_flow_w=python_reference_node_port_2 - python_reference_port_1, - heat_flow_w=rhs_diagnostic.thermal_energy_flow_w, - ) - return TestMqlPnl0001EnergyFlowDiagnostic( - line_alias=line_alias, - chamber_alias=chamber_alias, - node_alias=node_alias, - time_s=time_s, - amesim_port_1_enthalpy_flow_w=amesim_dh1, - amesim_port_1_mass_flow_g_s=amesim_dm1, - amesim_node_port_2_enthalpy_flow_w=amesim_node_dh2, - amesim_node_port_2_mass_flow_g_s=amesim_node_dm2, - amesim_observed_port_enthalpy_sum_w=amesim_dh1 + amesim_node_dh2, - python_port_1_internal_energy_flow_w=rhs_diagnostic.port_1_energy_flow_w, - python_port_2_internal_energy_flow_w=rhs_diagnostic.port_2_energy_flow_w, - python_current_energy_derivative_w=rhs_diagnostic.energy_derivative_w, - python_direct_enthalpy_port_1_flow_w=direct_port_1, - python_direct_enthalpy_port_2_flow_w=direct_port_2, - python_direct_enthalpy_energy_derivative_w=direct_energy_derivative, - python_direct_minus_current_energy_derivative_w=( - direct_energy_derivative - rhs_diagnostic.energy_derivative_w - ), - python_p4_node_port_2_enthalpy_flow_w=( - snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w - ), - python_p4_node_port_2_mass_flow_g_s=( - snapshot.p4_port3_remote_balance.port_2_mass_flow_g_s - ), - python_p4_node_port_2_connected_h_j_kg=p4_node_port_2_connected_h, - python_p4_node_port_2_connected_temperature_k=( - p4_node_port_2_connected_h / line.gas.cp - ), - python_p4_node_port_2_connected_h_delta_to_line_h_j_kg=( - p4_node_port_2_connected_h - line_properties.h - ), - python_p4_node_port_2_counterfactual_dtemp_k_s=( - p4_node_port_2_counterfactual_dtemp - ), - reference_temperature_k=reference_temperature_k, - amesim_port_1_reference_enthalpy_estimate_w=reference_enthalpy_estimate, - amesim_port_1_reference_enthalpy_error_w=( - reference_enthalpy_estimate - amesim_dh1 - ), - amesim_candidate_pn2pipefr_dm2i_g_s=candidate_pn2pipefr_dm2i_g_s, - amesim_candidate_pn2pipefr_dh2i_w=candidate_pn2pipefr_dh2i, - amesim_candidate_storage_enthalpy_sum_w=candidate_storage_sdh, - amesim_candidate_pn2pipefr_dtemp_k_s=candidate_pn2pipefr_dtemp, - amesim_candidate_pn2pipefr_dtemp_residual_k_s=( - candidate_pn2pipefr_dtemp - amesim_line_dtemp_fd - ), - python_reference_port_1_enthalpy_flow_w=python_reference_port_1, - python_reference_node_port_2_enthalpy_flow_w=python_reference_node_port_2, - python_reference_observed_port_enthalpy_sum_w=python_reference_sum, - python_reference_port_1_to_amesim_error_w=( - python_reference_port_1 - amesim_dh1 - ), - python_reference_node_port_2_to_amesim_error_w=( - python_reference_node_port_2 - amesim_node_dh2 - ), - python_reference_sum_to_amesim_error_w=( - python_reference_sum - (amesim_dh1 + amesim_node_dh2) - ), - python_current_line_temperature_derivative_k_s=current_line_dtemp, - amesim_line_temperature_derivative_fd_k_s=amesim_line_dtemp_fd, - amesim_pn2vol2_dtemp_node_plus_dh1_k_s=amesim_node_plus_dh1_dtemp, - amesim_pn2vol2_dtemp_node_minus_dh1_k_s=amesim_node_minus_dh1_dtemp, - python_reference_pn2vol2_dtemp_node_minus_dh1_k_s=( - python_reference_node_minus_dh1_dtemp - ), - ) - -def _p4_node_enthalpy_breakdown_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - state_vector: list[float], - node_alias: str, - time_s: float, -) -> TestMqlP4NodeEnthalpyBreakdownDiagnostic: - if node_alias != "pnnode4_16": - raise KeyError(f"Unsupported P4 node enthalpy diagnostic: {node_alias}") - - snapshot = closure.snapshot_at(time_s, state_vector).pneumatic - line = closure.pneumatic_closure.components.p4_port3_remote_primary_line - balance = snapshot.p4_port3_remote_balance - reference_temperature_k = 298.15 - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_port_1_enthalpy = amesim_value("dh1@pneumatic_83") - amesim_port_1_mass = amesim_value("dm1@pneumatic_83") - amesim_port_3_enthalpy = amesim_value("dh2@pneumatic_95") - amesim_port_3_mass = amesim_value("dm2@pneumatic_95") - amesim_port_4_enthalpy = amesim_value("dh2@pn_morifice_1") - amesim_port_4_mass = amesim_value("dm2@pn_morifice_1") - amesim_port_2_enthalpy = amesim_value(f"dh2@{node_alias}") - amesim_port_2_mass = amesim_value(f"dm2@{node_alias}") - - reference_port_1 = _reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, - node_temperature_k=snapshot.p4_port3_remote_primary_line.T, - connected_temperature_k=snapshot.p4_port3_line.T, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, - ) - reference_port_3 = _reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, - node_temperature_k=snapshot.p4_port3_remote_primary_line.T, - connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, - ) - reference_port_4 = _reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, - connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, - port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - ) - reference_port_2 = reference_port_1 + reference_port_3 + reference_port_4 - real_gas_reference_port_1 = _real_gas_reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_node_to_line_flow, - node_pressure_pa=snapshot.p4_port3_remote_primary_line.p, - node_temperature_k=snapshot.p4_port3_remote_primary_line.T, - connected_pressure_pa=snapshot.p4_port3_line.p, - connected_temperature_k=snapshot.p4_port3_line.T, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_node_to_line_flow, - ) - real_gas_reference_port_3 = _real_gas_reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_to_port3_line_flow, - node_pressure_pa=snapshot.p4_port3_remote_primary_line.p, - node_temperature_k=snapshot.p4_port3_remote_primary_line.T, - connected_pressure_pa=snapshot.p4_port3_remote_port3_line.p, - connected_temperature_k=snapshot.p4_port3_remote_port3_line.T, - port_mass_flow_kg_s=-snapshot.p4_port3_remote_to_port3_line_flow, - ) - real_gas_reference_port_4 = _real_gas_reference_enthalpy_flow_for_node_port( - gas=line.gas, - reference_temperature_k=reference_temperature_k, - flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - node_pressure_pa=snapshot.p4_port3_remote_orifice_line_port_2.p, - node_temperature_k=snapshot.p4_port3_remote_orifice_line_port_2.T, - connected_pressure_pa=snapshot.p4_port3_remote_primary_line.p, - connected_temperature_k=snapshot.p4_port3_remote_primary_line.T, - port_mass_flow_kg_s=snapshot.p4_port3_remote_orifice_to_node_flow, - ) - real_gas_reference_port_2 = ( - real_gas_reference_port_1 - + real_gas_reference_port_3 - + real_gas_reference_port_4 - ) - - return TestMqlP4NodeEnthalpyBreakdownDiagnostic( - node_alias=node_alias, - time_s=time_s, - amesim_port_1_enthalpy_flow_w=amesim_port_1_enthalpy, - amesim_port_1_mass_flow_g_s=amesim_port_1_mass, - amesim_port_3_enthalpy_flow_w=amesim_port_3_enthalpy, - amesim_port_3_mass_flow_g_s=amesim_port_3_mass, - amesim_port_4_enthalpy_flow_w=amesim_port_4_enthalpy, - amesim_port_4_mass_flow_g_s=amesim_port_4_mass, - amesim_port_2_enthalpy_flow_w=amesim_port_2_enthalpy, - amesim_port_2_mass_flow_g_s=amesim_port_2_mass, - python_port_1_enthalpy_flow_w=balance.port_1_enthalpy_flow_w, - python_port_1_mass_flow_g_s=balance.port_1_mass_flow_g_s, - python_port_3_enthalpy_flow_w=balance.port_3_enthalpy_flow_w, - python_port_3_mass_flow_g_s=balance.port_3_mass_flow_g_s, - python_port_4_enthalpy_flow_w=balance.port_4_enthalpy_flow_w, - python_port_4_mass_flow_g_s=balance.port_4_mass_flow_g_s, - python_port_2_enthalpy_flow_w=balance.port_2_enthalpy_flow_w, - python_port_2_mass_flow_g_s=balance.port_2_mass_flow_g_s, - python_reference_port_1_enthalpy_flow_w=reference_port_1, - python_reference_port_3_enthalpy_flow_w=reference_port_3, - python_reference_port_4_enthalpy_flow_w=reference_port_4, - python_reference_port_2_enthalpy_flow_w=reference_port_2, - python_real_gas_reference_port_1_enthalpy_flow_w=real_gas_reference_port_1, - python_real_gas_reference_port_3_enthalpy_flow_w=real_gas_reference_port_3, - python_real_gas_reference_port_4_enthalpy_flow_w=real_gas_reference_port_4, - python_real_gas_reference_port_2_enthalpy_flow_w=real_gas_reference_port_2, - python_port_2_enthalpy_error_w=( - balance.port_2_enthalpy_flow_w - amesim_port_2_enthalpy - ), - python_reference_port_2_enthalpy_error_w=( - reference_port_2 - amesim_port_2_enthalpy - ), - python_real_gas_reference_port_2_enthalpy_error_w=( - real_gas_reference_port_2 - amesim_port_2_enthalpy - ), - ) - - -def _pnvo_upstream_enthalpy_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - state_vector: list[float], - orifice_alias: str, - upstream_line_alias: str, - downstream_node_alias: str, - time_s: float, -) -> TestMqlPnvoUpstreamEnthalpyDiagnostic: - if orifice_alias != "pn_morifice_1": - raise KeyError(f"Unsupported PNVO upstream diagnostic: {orifice_alias}") - if upstream_line_alias != "pneumatic_87": - raise KeyError(f"Unsupported PNVO upstream line diagnostic: {upstream_line_alias}") - if downstream_node_alias != "pnnode4_16": - raise KeyError(f"Unsupported PNVO downstream node diagnostic: {downstream_node_alias}") - - snapshot = closure.snapshot_at(time_s, state_vector).pneumatic - line = closure.pneumatic_closure.components.p4_port3_remote_orifice_line - flow_kg_s = snapshot.p4_port3_remote_orifice_to_node_flow - reference_temperature_k = 298.15 - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_orifice_dh2 = amesim_value(f"dh2@{orifice_alias}") - amesim_orifice_dm2 = amesim_value(f"dm2@{orifice_alias}") - amesim_line_t2 = amesim_value(f"t2@{upstream_line_alias}") - if amesim_orifice_dm2 >= 0.0: - amesim_stream_temperature = amesim_line_t2 - amesim_stream_pressure = ( - amesim_value(f"p2@{upstream_line_alias}") - + AMESIM_REFERENCE_PRESSURE_PA - ) - else: - amesim_stream_temperature = amesim_value(f"temp1@{downstream_node_alias}") - amesim_stream_pressure = ( - amesim_value(f"press1@{downstream_node_alias}") - + AMESIM_REFERENCE_PRESSURE_PA - ) - amesim_dm2_kg_s = amesim_orifice_dm2 * 1.0e-3 - amesim_reference_dh2 = amesim_dm2_kg_s * line.gas.specific_reference_enthalpy( - amesim_stream_temperature, - reference_temperature_k, - ) - amesim_real_gas_reference_dh2 = ( - amesim_dm2_kg_s - * line.gas.pressure_reference_enthalpy( - amesim_stream_pressure, - amesim_stream_temperature, - reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, - reference_temperature=reference_temperature_k, - ) - ) - if abs(amesim_orifice_dm2) <= 1.0e-12: - amesim_implied_temperature = amesim_stream_temperature - else: - amesim_implied_temperature = line.gas.reference_temperature_from_specific_enthalpy( - amesim_orifice_dh2 / (amesim_orifice_dm2 * 1.0e-3), - reference_temperature_k, - ) - - python_stream_properties = ( - snapshot.p4_port3_remote_orifice_line_port_2 - if flow_kg_s >= 0.0 - else snapshot.p4_port3_remote_primary_line - ) - python_current_dh2 = flow_kg_s * python_stream_properties.h - python_reference_dh2 = flow_kg_s * line.gas.specific_reference_enthalpy( - python_stream_properties.T, - reference_temperature_k, - ) - python_real_gas_reference_dh2 = flow_kg_s * line.gas.pressure_reference_enthalpy( - python_stream_properties.p, - python_stream_properties.T, - reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, - reference_temperature=reference_temperature_k, - ) - - return TestMqlPnvoUpstreamEnthalpyDiagnostic( - orifice_alias=orifice_alias, - upstream_line_alias=upstream_line_alias, - time_s=time_s, - amesim_orifice_port_2_enthalpy_flow_w=amesim_orifice_dh2, - amesim_orifice_port_2_mass_flow_g_s=amesim_orifice_dm2, - amesim_orifice_port_3_enthalpy_flow_w=amesim_value( - f"dh3@{orifice_alias}" - ), - amesim_orifice_port_3_mass_flow_g_s=amesim_value( - f"dm3@{orifice_alias}" - ), - amesim_upstream_line_center_enthalpy_flow_w=amesim_value( - f"dhctr@{upstream_line_alias}" - ), - amesim_upstream_line_center_mass_flow_g_s=amesim_value( - f"dmctr@{upstream_line_alias}" - ), - amesim_upstream_line_port_2_temperature_k=amesim_line_t2, - amesim_upstream_line_port_2_pressure_pa=amesim_stream_pressure, - amesim_orifice_port_2_implied_reference_temperature_k=( - amesim_implied_temperature - ), - amesim_orifice_port_2_implied_temperature_delta_to_line_k=( - amesim_implied_temperature - amesim_line_t2 - ), - amesim_upstream_line_port_2_reference_enthalpy_flow_w=amesim_reference_dh2, - amesim_upstream_line_port_2_reference_enthalpy_error_w=( - amesim_reference_dh2 - amesim_orifice_dh2 - ), - amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w=( - amesim_real_gas_reference_dh2 - ), - amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w=( - amesim_real_gas_reference_dh2 - amesim_orifice_dh2 - ), - python_orifice_to_node_flow_kg_s=flow_kg_s, - python_upstream_line_port_2_temperature_k=( - snapshot.p4_port3_remote_orifice_line_port_2.T - ), - python_upstream_line_port_2_pressure_pa=python_stream_properties.p, - python_current_port_2_enthalpy_flow_w=python_current_dh2, - python_reference_port_2_enthalpy_flow_w=python_reference_dh2, - python_real_gas_reference_port_2_enthalpy_flow_w=( - python_real_gas_reference_dh2 - ), - python_current_port_2_enthalpy_error_w=( - python_current_dh2 - amesim_orifice_dh2 - ), - python_reference_port_2_enthalpy_error_w=( - python_reference_dh2 - amesim_orifice_dh2 - ), - python_real_gas_reference_port_2_enthalpy_error_w=( - python_real_gas_reference_dh2 - amesim_orifice_dh2 - ), - ) - - -def _pn2vol_reference_dtemp( - *, - gas: AmesimPneumaticGas, - temperature_k: float, - mass_kg: float, - mass_derivative_kg_s: float, - enthalpy_flow_w: float, - heat_flow_w: float, - reference_temperature_k: float = 298.15, -) -> float: - reference_offset_flow_w = ( - gas.cp * reference_temperature_k - gas.cv * temperature_k - ) * mass_derivative_kg_s - return (enthalpy_flow_w + reference_offset_flow_w + heat_flow_w) / ( - mass_kg * gas.cv - ) - - -def _actual_reference_enthalpy_flow( - *, - gas: AmesimPneumaticGas, - port_m_flow: float, - connected_pressure_pa: float, - connected_temperature_k: float, - internal_pressure_pa: float, - internal_temperature_k: float, - reference_temperature_k: float = 298.15, -) -> float: - if port_m_flow > 0.0: - pressure = connected_pressure_pa - temperature = connected_temperature_k - else: - pressure = internal_pressure_pa - temperature = internal_temperature_k - return port_m_flow * gas.pressure_reference_enthalpy( - pressure, - temperature, - reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, - reference_temperature=reference_temperature_k, - ) - - -def _pressure_derivative_components_from_density_temperature( - *, - gas: AmesimPneumaticGas, - density_kg_m3: float, - temperature_k: float, - density_derivative_kg_m3_s: float, - temperature_derivative_k_s: float, -) -> tuple[float, float, float]: - density_step = max(abs(density_kg_m3) * 1.0e-6, 1.0e-9) - if density_kg_m3 <= density_step: - density_step = density_kg_m3 * 0.5 - temperature_step = max(abs(temperature_k) * 1.0e-6, 1.0e-4) - dp_drho = ( - gas.pressure(density_kg_m3 + density_step, temperature_k) - - gas.pressure(density_kg_m3 - density_step, temperature_k) - ) / (2.0 * density_step) - dp_dtemp = ( - gas.pressure(density_kg_m3, temperature_k + temperature_step) - - gas.pressure(density_kg_m3, temperature_k - temperature_step) - ) / (2.0 * temperature_step) - mass_component = dp_drho * density_derivative_kg_m3_s - temperature_component = dp_dtemp * temperature_derivative_k_s - return mass_component, temperature_component, mass_component + temperature_component - - -def _pnl0003_energy_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - state_vector: list[float], - line_alias: str, - time_s: float, -) -> TestMqlPnl0003EnergyDiagnostic: - if line_alias != "pneumatic_87": - raise KeyError(f"Unsupported PNL0003 energy diagnostic: {line_alias}") - - snapshot = closure.snapshot_at(time_s, state_vector).pneumatic - line = closure.pneumatic_closure.components.p4_port3_remote_orifice_line - port_1 = snapshot.p4_port3_remote_orifice_line_port_1 - port_2 = snapshot.p4_port3_remote_orifice_line_port_2 - connected_port_2 = snapshot.p4_port3_remote_primary_line - center_flow = snapshot.p4_port3_remote_orifice_line_center_flow - port_1_flow = snapshot.p4_port3_remote_node_to_orifice_line_flow - port_2_flow = -snapshot.p4_port3_remote_orifice_to_node_flow - reference_temperature_k = 298.15 - pn3_port_2_mass_flow_kg_s = ( - snapshot.p4_port3_remote_orifice_node_balance.port_2_mass_flow_g_s * 1.0e-3 - ) - if abs(pn3_port_2_mass_flow_kg_s) <= 1.0e-12: - port_1_connected_h = port_1.h - else: - port_1_connected_h = ( - snapshot.p4_port3_remote_orifice_node_balance.port_2_enthalpy_flow_w - / pn3_port_2_mass_flow_kg_s - ) - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_t1 = amesim_value(f"t1@{line_alias}") - amesim_t2 = amesim_value(f"t2@{line_alias}") - amesim_p1 = amesim_value(f"p1@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA - amesim_p2 = amesim_value(f"p2@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA - amesim_dm1 = amesim_value("dm2@pn_node3_8") - amesim_dh1 = amesim_value("dh2@pn_node3_8") - python_node_dm1 = snapshot.p4_port3_remote_orifice_node_balance.port_1_mass_flow_g_s - python_node_dm3 = snapshot.p4_port3_remote_orifice_node_balance.port_3_mass_flow_g_s - python_node_dm2_sum = python_node_dm1 + python_node_dm3 - python_node_to_line_dm = -python_node_dm2_sum - amesim_dm2 = amesim_value("dm3@pn_morifice_1") - amesim_dh2 = amesim_value("dh3@pn_morifice_1") - amesim_dmctr = amesim_value(f"dmctr@{line_alias}") - amesim_dhctr = amesim_value(f"dhctr@{line_alias}") - amesim_sdm1_kg_s = (amesim_dm1 + amesim_dmctr) * 1.0e-3 - amesim_sdm2_kg_s = (amesim_dm2 - amesim_dmctr) * 1.0e-3 - amesim_sdh1 = amesim_dh1 + amesim_dhctr - amesim_sdh2 = amesim_dh2 - amesim_dhctr - amesim_mgas_series = amesim_results.series(f"mgas@{line_alias}") - amesim_total_mass_derivative_fd = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_mgas_series, - time_s=time_s, - ) - ( - amesim_total_mass_derivative_backward, - amesim_total_mass_derivative_forward, - ) = _series_one_sided_differences_at( - times=amesim_results.times, - values=amesim_mgas_series, - time_s=time_s, - ) - amesim_total_storage_mass_derivative = ( - amesim_sdm1_kg_s + amesim_sdm2_kg_s - ) * 1.0e3 - python_center_mass_flow_g_s = center_flow * 1.0e3 - amesim_center_python_sign_equivalent_g_s = -amesim_dmctr - amesim_center_mass_flow_python_sign_kg_s = ( - amesim_center_python_sign_equivalent_g_s * 1.0e-3 - ) - amesim_port_1_center_mass_derivative_g_s = amesim_dmctr - amesim_port_2_center_mass_derivative_g_s = -amesim_dmctr - python_port_1_external_mass_flow_g_s = port_1_flow * 1.0e3 - python_port_1_center_mass_derivative_g_s = -center_flow * 1.0e3 - python_port_1_storage_mass_derivative_g_s = ( - python_port_1_external_mass_flow_g_s - + python_port_1_center_mass_derivative_g_s - ) - python_port_2_external_mass_flow_g_s = port_2_flow * 1.0e3 - python_port_2_center_mass_derivative_g_s = center_flow * 1.0e3 - python_port_2_storage_mass_derivative_g_s = ( - python_port_2_external_mass_flow_g_s - + python_port_2_center_mass_derivative_g_s - ) - - def current_darcy_center_flow_for_state( - port_1_pressure_pa: float, - port_1_temperature_k: float, - port_2_pressure_pa: float, - port_2_temperature_k: float, - ) -> float: - pressure_difference = port_1_pressure_pa - port_2_pressure_pa - if pressure_difference == 0.0: - return 0.0 - upstream_pressure = ( - port_1_pressure_pa if pressure_difference > 0.0 else port_2_pressure_pa - ) - upstream_temperature = ( - port_1_temperature_k if pressure_difference > 0.0 else port_2_temperature_k - ) - upstream_density = line.gas.density(upstream_pressure, upstream_temperature) - magnitude = line._mass_flow_for_pressure_drop( - abs(pressure_difference), - density=upstream_density, - temperature=upstream_temperature, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - amesim_state_darcy_center_flow = current_darcy_center_flow_for_state( - amesim_p1, - amesim_t1, - amesim_p2, - amesim_t2, - ) - amesim_state_pn2pipefr_center_flow = line.pn2pipefr_mass_flow( - port_1_pressure_pa=amesim_p1, - port_1_temperature_k=amesim_t1, - port_2_pressure_pa=amesim_p2, - port_2_temperature_k=amesim_t2, - ) - amesim_center_real_port_1 = ( - -amesim_center_mass_flow_python_sign_kg_s - * line.gas.pressure_reference_enthalpy( - amesim_p1, - amesim_t1, - reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, - reference_temperature=reference_temperature_k, - ) - ) - amesim_mass_1 = line.gas.density(amesim_p1, amesim_t1) * line.compliance_volume - amesim_mass_2 = line.gas.density(amesim_p2, amesim_t2) * line.compliance_volume - heat_flow_1 = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * 0.5 - * (line.external_temperature - amesim_t1) - ) - heat_flow_2 = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * 0.5 - * (line.external_temperature - amesim_t2) - ) - amesim_pn2vol_dtemp_1 = _pn2vol_reference_dtemp( - gas=line.gas, - temperature_k=amesim_t1, - mass_kg=amesim_mass_1, - mass_derivative_kg_s=amesim_sdm1_kg_s, - enthalpy_flow_w=amesim_sdh1, - heat_flow_w=heat_flow_1, - reference_temperature_k=reference_temperature_k, - ) - amesim_pn2vol_dtemp_2 = _pn2vol_reference_dtemp( - gas=line.gas, - temperature_k=amesim_t2, - mass_kg=amesim_mass_2, - mass_derivative_kg_s=amesim_sdm2_kg_s, - enthalpy_flow_w=amesim_sdh2, - heat_flow_w=heat_flow_2, - reference_temperature_k=reference_temperature_k, - ) - amesim_t1_series = amesim_results.series(f"t1@{line_alias}") - amesim_t2_series = amesim_results.series(f"t2@{line_alias}") - amesim_fd_dtemp_1 = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_t1_series, - time_s=time_s, - ) - amesim_fd_dtemp_2 = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_t2_series, - time_s=time_s, - ) - amesim_backward_dtemp_1, amesim_forward_dtemp_1 = ( - _series_one_sided_differences_at( - times=amesim_results.times, - values=amesim_t1_series, - time_s=time_s, - ) - ) - amesim_backward_dtemp_2, amesim_forward_dtemp_2 = ( - _series_one_sided_differences_at( - times=amesim_results.times, - values=amesim_t2_series, - time_s=time_s, - ) - ) - amesim_p1_series = amesim_results.series(f"p1@{line_alias}") - amesim_p2_series = amesim_results.series(f"p2@{line_alias}") - amesim_fd_dpressure_1 = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_p1_series, - time_s=time_s, - ) - amesim_fd_dpressure_2 = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_p2_series, - time_s=time_s, - ) - ( - amesim_dpressure_1_mass, - amesim_dpressure_1_temperature, - amesim_dpressure_1_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=amesim_mass_1 / line.compliance_volume, - temperature_k=amesim_t1, - density_derivative_kg_m3_s=amesim_sdm1_kg_s / line.compliance_volume, - temperature_derivative_k_s=amesim_fd_dtemp_1, - ) - ( - amesim_dpressure_2_mass, - amesim_dpressure_2_temperature, - amesim_dpressure_2_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=amesim_mass_2 / line.compliance_volume, - temperature_k=amesim_t2, - density_derivative_kg_m3_s=amesim_sdm2_kg_s / line.compliance_volume, - temperature_derivative_k_s=amesim_fd_dtemp_2, - ) - - current_derivative_1, current_derivative_2 = line.derivatives_from_connections( - port_1_m_flow=port_1_flow, - connected_h_1=port_1_connected_h, - port_2_m_flow=port_2_flow, - connected_h_2=connected_port_2.h, - ) - python_current_dtemp_1 = ( - current_derivative_1.U - port_1.u * current_derivative_1.m - ) / (line.state_1.m * line.gas.cv) - python_current_dtemp_2 = ( - current_derivative_2.U - port_2.u * current_derivative_2.m - ) / (line.state_2.m * line.gas.cv) - ( - python_dpressure_1_mass, - python_dpressure_1_temperature, - python_dpressure_1_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=line.state_1.m / line.compliance_volume, - temperature_k=port_1.T, - density_derivative_kg_m3_s=current_derivative_1.m / line.compliance_volume, - temperature_derivative_k_s=python_current_dtemp_1, - ) - ( - python_dpressure_2_mass, - python_dpressure_2_temperature, - python_dpressure_2_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=line.state_2.m / line.compliance_volume, - temperature_k=port_2.T, - density_derivative_kg_m3_s=current_derivative_2.m / line.compliance_volume, - temperature_derivative_k_s=python_current_dtemp_2, - ) - - python_center_dh_1 = _actual_reference_enthalpy_flow( - gas=line.gas, - port_m_flow=-center_flow, - connected_pressure_pa=port_2.p, - connected_temperature_k=port_2.T, - internal_pressure_pa=port_1.p, - internal_temperature_k=port_1.T, - reference_temperature_k=reference_temperature_k, - ) - python_center_dh_2 = _actual_reference_enthalpy_flow( - gas=line.gas, - port_m_flow=center_flow, - connected_pressure_pa=port_1.p, - connected_temperature_k=port_1.T, - internal_pressure_pa=port_2.p, - internal_temperature_k=port_2.T, - reference_temperature_k=reference_temperature_k, - ) - if port_1_flow > 0.0: - python_port_1_dh = port_1_flow * ( - port_1_connected_h - line.gas.cp * reference_temperature_k - ) - else: - python_port_1_dh = _actual_reference_enthalpy_flow( - gas=line.gas, - port_m_flow=port_1_flow, - connected_pressure_pa=port_1.p, - connected_temperature_k=port_1.T, - internal_pressure_pa=port_1.p, - internal_temperature_k=port_1.T, - reference_temperature_k=reference_temperature_k, - ) - python_port_2_dh = _actual_reference_enthalpy_flow( - gas=line.gas, - port_m_flow=port_2_flow, - connected_pressure_pa=connected_port_2.p, - connected_temperature_k=connected_port_2.T, - internal_pressure_pa=port_2.p, - internal_temperature_k=port_2.T, - reference_temperature_k=reference_temperature_k, - ) - python_center_ideal_port_1 = -center_flow * line.gas.specific_reference_enthalpy( - port_1.T, - reference_temperature_k, - ) - python_center_ideal_port_2 = -center_flow * line.gas.specific_reference_enthalpy( - port_2.T, - reference_temperature_k, - ) - python_center_real_port_1 = -center_flow * line.gas.pressure_reference_enthalpy( - port_1.p, - port_1.T, - reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, - reference_temperature=reference_temperature_k, - ) - python_center_real_port_2 = -center_flow * line.gas.pressure_reference_enthalpy( - port_2.p, - port_2.T, - reference_pressure=AMESIM_REFERENCE_PRESSURE_PA, - reference_temperature=reference_temperature_k, - ) - - python_heat_flow_1 = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * 0.5 - * (line.external_temperature - port_1.T) - ) - python_heat_flow_2 = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * 0.5 - * (line.external_temperature - port_2.T) - ) - python_ref_dtemp_1 = _pn2vol_reference_dtemp( - gas=line.gas, - temperature_k=port_1.T, - mass_kg=line.state_1.m, - mass_derivative_kg_s=port_1_flow - center_flow, - enthalpy_flow_w=python_port_1_dh + python_center_dh_1, - heat_flow_w=python_heat_flow_1, - reference_temperature_k=reference_temperature_k, - ) - python_ref_dtemp_2 = _pn2vol_reference_dtemp( - gas=line.gas, - temperature_k=port_2.T, - mass_kg=line.state_2.m, - mass_derivative_kg_s=port_2_flow + center_flow, - enthalpy_flow_w=python_port_2_dh + python_center_dh_2, - heat_flow_w=python_heat_flow_2, - reference_temperature_k=reference_temperature_k, - ) - - def dtemp_component(flow_w: float, mass_kg: float) -> float: - return flow_w / (mass_kg * line.gas.cv) - - amesim_port_1_mass_offset_flow_w = ( - line.gas.cp * reference_temperature_k - line.gas.cv * amesim_t1 - ) * amesim_sdm1_kg_s - amesim_port_2_mass_offset_flow_w = ( - line.gas.cp * reference_temperature_k - line.gas.cv * amesim_t2 - ) * amesim_sdm2_kg_s - python_port_1_mass_offset_flow_w = ( - line.gas.cp * reference_temperature_k - line.gas.cv * port_1.T - ) * (port_1_flow - center_flow) - python_port_2_mass_offset_flow_w = ( - line.gas.cp * reference_temperature_k - line.gas.cv * port_2.T - ) * (port_2_flow + center_flow) - amesim_port_1_external_dtemp_component = dtemp_component( - amesim_dh1, - amesim_mass_1, - ) - amesim_port_1_center_dtemp_component = dtemp_component( - amesim_dhctr, - amesim_mass_1, - ) - amesim_port_1_mass_offset_dtemp_component = dtemp_component( - amesim_port_1_mass_offset_flow_w, - amesim_mass_1, - ) - amesim_port_1_heat_dtemp_component = dtemp_component( - heat_flow_1, - amesim_mass_1, - ) - amesim_port_2_external_dtemp_component = dtemp_component( - amesim_dh2, - amesim_mass_2, - ) - amesim_port_2_center_dtemp_component = dtemp_component( - -amesim_dhctr, - amesim_mass_2, - ) - amesim_port_2_mass_offset_dtemp_component = dtemp_component( - amesim_port_2_mass_offset_flow_w, - amesim_mass_2, - ) - amesim_port_2_heat_dtemp_component = dtemp_component( - heat_flow_2, - amesim_mass_2, - ) - python_port_1_external_dtemp_component = dtemp_component( - python_port_1_dh, - line.state_1.m, - ) - python_port_1_center_dtemp_component = dtemp_component( - python_center_dh_1, - line.state_1.m, - ) - python_port_1_mass_offset_dtemp_component = dtemp_component( - python_port_1_mass_offset_flow_w, - line.state_1.m, - ) - python_port_1_heat_dtemp_component = dtemp_component( - python_heat_flow_1, - line.state_1.m, - ) - python_port_2_external_dtemp_component = dtemp_component( - python_port_2_dh, - line.state_2.m, - ) - python_port_2_center_dtemp_component = dtemp_component( - python_center_dh_2, - line.state_2.m, - ) - python_port_2_mass_offset_dtemp_component = dtemp_component( - python_port_2_mass_offset_flow_w, - line.state_2.m, - ) - python_port_2_heat_dtemp_component = dtemp_component( - python_heat_flow_2, - line.state_2.m, - ) - - amesim_center_flow_python_sign_kg_s = ( - amesim_center_python_sign_equivalent_g_s * 1.0e-3 - ) - amesim_external_flow_kg_s = amesim_dm2 * 1.0e-3 - python_dtemp_2_amesim_center = _pn2vol_reference_dtemp( - gas=line.gas, - temperature_k=port_2.T, - mass_kg=line.state_2.m, - mass_derivative_kg_s=port_2_flow + amesim_center_flow_python_sign_kg_s, - enthalpy_flow_w=python_port_2_dh - amesim_dhctr, - heat_flow_w=python_heat_flow_2, - reference_temperature_k=reference_temperature_k, - ) - python_dtemp_2_amesim_external = _pn2vol_reference_dtemp( - gas=line.gas, - temperature_k=port_2.T, - mass_kg=line.state_2.m, - mass_derivative_kg_s=amesim_external_flow_kg_s + center_flow, - enthalpy_flow_w=amesim_dh2 + python_center_dh_2, - heat_flow_w=python_heat_flow_2, - reference_temperature_k=reference_temperature_k, - ) - python_dtemp_2_amesim_external_center = _pn2vol_reference_dtemp( - gas=line.gas, - temperature_k=port_2.T, - mass_kg=line.state_2.m, - mass_derivative_kg_s=( - amesim_external_flow_kg_s + amesim_center_flow_python_sign_kg_s - ), - enthalpy_flow_w=amesim_dh2 - amesim_dhctr, - heat_flow_w=python_heat_flow_2, - reference_temperature_k=reference_temperature_k, - ) - - return TestMqlPnl0003EnergyDiagnostic( - line_alias=line_alias, - time_s=time_s, - amesim_port_1_temperature_k=amesim_t1, - amesim_port_2_temperature_k=amesim_t2, - amesim_port_1_pressure_pa=amesim_p1, - amesim_port_2_pressure_pa=amesim_p2, - amesim_port_1_mass_flow_g_s=amesim_dm1, - amesim_port_1_enthalpy_flow_w=amesim_dh1, - amesim_center_mass_flow_g_s=amesim_dmctr, - amesim_center_enthalpy_flow_w=amesim_dhctr, - amesim_port_2_mass_flow_g_s=amesim_dm2, - amesim_port_2_enthalpy_flow_w=amesim_dh2, - amesim_port_1_storage_mass_derivative_g_s=amesim_sdm1_kg_s * 1.0e3, - amesim_port_1_storage_enthalpy_sum_w=amesim_sdh1, - amesim_port_2_storage_mass_derivative_g_s=amesim_sdm2_kg_s * 1.0e3, - amesim_port_2_storage_enthalpy_sum_w=amesim_sdh2, - python_pn3_node_port_1_mass_flow_g_s=python_node_dm1, - python_pn3_node_port_3_mass_flow_g_s=python_node_dm3, - python_pn3_node_to_line_mass_flow_g_s=python_node_to_line_dm, - python_pn3_node_to_line_mass_flow_error_g_s=( - python_node_to_line_dm - amesim_dm1 - ), - amesim_port_1_mass_estimate_g=amesim_mass_1 * 1.0e3, - amesim_port_2_mass_estimate_g=amesim_mass_2 * 1.0e3, - python_port_1_mass_g=line.state_1.m * 1.0e3, - python_port_2_mass_g=line.state_2.m * 1.0e3, - python_port_1_mass_error_to_amesim_g=( - line.state_1.m * 1.0e3 - amesim_mass_1 * 1.0e3 - ), - python_port_2_mass_error_to_amesim_g=( - line.state_2.m * 1.0e3 - amesim_mass_2 * 1.0e3 - ), - amesim_total_mass_derivative_fd_g_s=amesim_total_mass_derivative_fd, - amesim_total_mass_derivative_backward_g_s=( - amesim_total_mass_derivative_backward - ), - amesim_total_mass_derivative_forward_g_s=( - amesim_total_mass_derivative_forward - ), - amesim_total_storage_mass_derivative_g_s=( - amesim_total_storage_mass_derivative - ), - amesim_total_storage_mass_derivative_residual_g_s=( - amesim_total_storage_mass_derivative - amesim_total_mass_derivative_fd - ), - amesim_center_flow_python_sign_equivalent_g_s=( - amesim_center_python_sign_equivalent_g_s - ), - amesim_center_flow_python_sign_error_g_s=( - python_center_mass_flow_g_s - amesim_center_python_sign_equivalent_g_s - ), - python_center_mass_flow_g_s=python_center_mass_flow_g_s, - amesim_port_1_center_mass_derivative_g_s=( - amesim_port_1_center_mass_derivative_g_s - ), - amesim_port_2_center_mass_derivative_g_s=( - amesim_port_2_center_mass_derivative_g_s - ), - python_port_1_external_mass_flow_g_s=python_port_1_external_mass_flow_g_s, - python_port_1_external_mass_flow_error_g_s=( - python_port_1_external_mass_flow_g_s - amesim_dm1 - ), - python_port_1_center_mass_derivative_g_s=( - python_port_1_center_mass_derivative_g_s - ), - python_port_1_center_mass_derivative_error_g_s=( - python_port_1_center_mass_derivative_g_s - - amesim_port_1_center_mass_derivative_g_s - ), - python_port_1_storage_mass_derivative_g_s=( - python_port_1_storage_mass_derivative_g_s - ), - python_port_1_storage_mass_derivative_error_g_s=( - python_port_1_storage_mass_derivative_g_s - amesim_sdm1_kg_s * 1.0e3 - ), - python_port_2_external_mass_flow_g_s=python_port_2_external_mass_flow_g_s, - python_port_2_external_mass_flow_error_g_s=( - python_port_2_external_mass_flow_g_s - amesim_dm2 - ), - python_port_2_center_mass_derivative_g_s=( - python_port_2_center_mass_derivative_g_s - ), - python_port_2_center_mass_derivative_error_g_s=( - python_port_2_center_mass_derivative_g_s - - amesim_port_2_center_mass_derivative_g_s - ), - python_port_2_storage_mass_derivative_g_s=( - python_port_2_storage_mass_derivative_g_s - ), - python_port_2_storage_mass_derivative_error_g_s=( - python_port_2_storage_mass_derivative_g_s - amesim_sdm2_kg_s * 1.0e3 - ), - python_amesim_state_darcy_center_mass_flow_g_s=( - amesim_state_darcy_center_flow * 1.0e3 - ), - python_amesim_state_darcy_center_mass_flow_error_g_s=( - amesim_state_darcy_center_flow * 1.0e3 - - amesim_center_python_sign_equivalent_g_s - ), - python_amesim_state_pn2pipefr_center_mass_flow_g_s=( - amesim_state_pn2pipefr_center_flow * 1.0e3 - ), - python_amesim_state_pn2pipefr_center_mass_flow_error_g_s=( - amesim_state_pn2pipefr_center_flow * 1.0e3 - - amesim_center_python_sign_equivalent_g_s - ), - amesim_center_real_gas_reference_port_1_estimate_w=amesim_center_real_port_1, - amesim_center_real_gas_reference_port_1_error_w=( - amesim_center_real_port_1 - amesim_dhctr - ), - python_center_ideal_reference_port_1_estimate_w=python_center_ideal_port_1, - python_center_ideal_reference_port_1_error_w=( - python_center_ideal_port_1 - amesim_dhctr - ), - python_center_ideal_reference_port_2_estimate_w=python_center_ideal_port_2, - python_center_ideal_reference_port_2_error_w=( - python_center_ideal_port_2 - amesim_dhctr - ), - python_center_real_gas_reference_port_1_estimate_w=python_center_real_port_1, - python_center_real_gas_reference_port_1_error_w=( - python_center_real_port_1 - amesim_dhctr - ), - python_center_real_gas_reference_port_2_estimate_w=python_center_real_port_2, - python_center_real_gas_reference_port_2_error_w=( - python_center_real_port_2 - amesim_dhctr - ), - python_total_mass_derivative_kg_s=( - current_derivative_1.m + current_derivative_2.m - ), - python_port_1_reference_external_enthalpy_flow_w=python_port_1_dh, - python_port_1_reference_center_enthalpy_flow_w=python_center_dh_1, - python_port_1_reference_storage_enthalpy_sum_w=( - python_port_1_dh + python_center_dh_1 - ), - python_port_1_reference_storage_enthalpy_error_w=( - python_port_1_dh + python_center_dh_1 - amesim_sdh1 - ), - python_port_2_reference_external_enthalpy_flow_w=python_port_2_dh, - python_port_2_reference_center_enthalpy_flow_w=python_center_dh_2, - python_port_2_reference_storage_enthalpy_sum_w=( - python_port_2_dh + python_center_dh_2 - ), - python_port_2_reference_storage_enthalpy_error_w=( - python_port_2_dh + python_center_dh_2 - amesim_sdh2 - ), - amesim_port_1_external_enthalpy_dtemp_component_k_s=( - amesim_port_1_external_dtemp_component - ), - amesim_port_1_center_enthalpy_dtemp_component_k_s=( - amesim_port_1_center_dtemp_component - ), - amesim_port_1_mass_offset_dtemp_component_k_s=( - amesim_port_1_mass_offset_dtemp_component - ), - amesim_port_1_heat_dtemp_component_k_s=amesim_port_1_heat_dtemp_component, - python_port_1_external_enthalpy_dtemp_component_k_s=( - python_port_1_external_dtemp_component - ), - python_port_1_center_enthalpy_dtemp_component_k_s=( - python_port_1_center_dtemp_component - ), - python_port_1_mass_offset_dtemp_component_k_s=( - python_port_1_mass_offset_dtemp_component - ), - python_port_1_heat_dtemp_component_k_s=python_port_1_heat_dtemp_component, - amesim_port_2_external_enthalpy_dtemp_component_k_s=( - amesim_port_2_external_dtemp_component - ), - amesim_port_2_center_enthalpy_dtemp_component_k_s=( - amesim_port_2_center_dtemp_component - ), - amesim_port_2_mass_offset_dtemp_component_k_s=( - amesim_port_2_mass_offset_dtemp_component - ), - amesim_port_2_heat_dtemp_component_k_s=amesim_port_2_heat_dtemp_component, - python_port_2_external_enthalpy_dtemp_component_k_s=( - python_port_2_external_dtemp_component - ), - python_port_2_center_enthalpy_dtemp_component_k_s=( - python_port_2_center_dtemp_component - ), - python_port_2_mass_offset_dtemp_component_k_s=( - python_port_2_mass_offset_dtemp_component - ), - python_port_2_heat_dtemp_component_k_s=python_port_2_heat_dtemp_component, - amesim_port_1_temperature_derivative_fd_k_s=amesim_fd_dtemp_1, - amesim_port_1_temperature_derivative_backward_k_s=amesim_backward_dtemp_1, - amesim_port_1_temperature_derivative_forward_k_s=amesim_forward_dtemp_1, - amesim_port_2_temperature_derivative_fd_k_s=amesim_fd_dtemp_2, - amesim_port_2_temperature_derivative_backward_k_s=amesim_backward_dtemp_2, - amesim_port_2_temperature_derivative_forward_k_s=amesim_forward_dtemp_2, - amesim_port_1_pn2vol_dtemp_k_s=amesim_pn2vol_dtemp_1, - amesim_port_2_pn2vol_dtemp_k_s=amesim_pn2vol_dtemp_2, - amesim_port_1_pn2vol_dtemp_residual_k_s=( - amesim_pn2vol_dtemp_1 - amesim_fd_dtemp_1 - ), - amesim_port_2_pn2vol_dtemp_residual_k_s=( - amesim_pn2vol_dtemp_2 - amesim_fd_dtemp_2 - ), - amesim_port_1_pressure_derivative_fd_pa_s=amesim_fd_dpressure_1, - amesim_port_2_pressure_derivative_fd_pa_s=amesim_fd_dpressure_2, - amesim_port_1_pressure_derivative_mass_component_pa_s=( - amesim_dpressure_1_mass - ), - amesim_port_1_pressure_derivative_temperature_component_pa_s=( - amesim_dpressure_1_temperature - ), - amesim_port_1_pressure_derivative_eos_sum_pa_s=amesim_dpressure_1_sum, - amesim_port_1_pressure_derivative_eos_residual_pa_s=( - amesim_dpressure_1_sum - amesim_fd_dpressure_1 - ), - amesim_port_2_pressure_derivative_mass_component_pa_s=( - amesim_dpressure_2_mass - ), - amesim_port_2_pressure_derivative_temperature_component_pa_s=( - amesim_dpressure_2_temperature - ), - amesim_port_2_pressure_derivative_eos_sum_pa_s=amesim_dpressure_2_sum, - amesim_port_2_pressure_derivative_eos_residual_pa_s=( - amesim_dpressure_2_sum - amesim_fd_dpressure_2 - ), - python_port_1_pressure_derivative_mass_component_pa_s=( - python_dpressure_1_mass - ), - python_port_1_pressure_derivative_temperature_component_pa_s=( - python_dpressure_1_temperature - ), - python_port_1_pressure_derivative_eos_sum_pa_s=python_dpressure_1_sum, - python_port_2_pressure_derivative_mass_component_pa_s=( - python_dpressure_2_mass - ), - python_port_2_pressure_derivative_temperature_component_pa_s=( - python_dpressure_2_temperature - ), - python_port_2_pressure_derivative_eos_sum_pa_s=python_dpressure_2_sum, - python_port_1_temperature_k=port_1.T, - python_port_2_temperature_k=port_2.T, - python_port_1_pressure_pa=port_1.p, - python_port_2_pressure_pa=port_2.p, - python_port_1_mass_derivative_kg_s=current_derivative_1.m, - python_port_2_mass_derivative_kg_s=current_derivative_2.m, - python_port_1_current_dtemp_k_s=python_current_dtemp_1, - python_port_2_current_dtemp_k_s=python_current_dtemp_2, - python_port_1_real_gas_reference_dtemp_k_s=python_ref_dtemp_1, - python_port_2_real_gas_reference_dtemp_k_s=python_ref_dtemp_2, - python_port_2_amesim_center_counterfactual_dtemp_k_s=( - python_dtemp_2_amesim_center - ), - python_port_2_amesim_external_counterfactual_dtemp_k_s=( - python_dtemp_2_amesim_external - ), - python_port_2_amesim_external_center_counterfactual_dtemp_k_s=( - python_dtemp_2_amesim_external_center - ), - python_port_2_temperature_error_to_amesim_k=port_2.T - amesim_t2, - ) - - -def _pneumatic_96_inlet_line_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - state_vector: list[float], - line_alias: str, - node_alias: str, - orifice_alias: str, - chamber_alias: str, - time_s: float, -) -> TestMqlPneumatic96InletLineDiagnostic: - if line_alias != "pneumatic_96": - raise KeyError(f"Unsupported inlet line diagnostic: {line_alias}") - if node_alias != "pn_node3_8" or orifice_alias != "pn_orifice_18": - raise KeyError( - f"Unsupported inlet path diagnostic: {node_alias}/{orifice_alias}" - ) - - snapshot = closure.snapshot_at(time_s, state_vector).pneumatic - line = closure.pneumatic_closure.components.inlet_line - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_line_pressure = ( - amesim_value(f"p2@{line_alias}") + AMESIM_REFERENCE_PRESSURE_PA - ) - amesim_line_temperature = amesim_value(f"t2@{line_alias}") - amesim_line_mass = amesim_value(f"mgas@{line_alias}") - amesim_line_mass_fd = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_results.series(f"mgas@{line_alias}"), - time_s=time_s, - ) - amesim_line_to_node_flow = amesim_value(f"dm1@{line_alias}") - amesim_orifice_to_line_flow = amesim_value(f"dm2@{orifice_alias}") - amesim_storage_from_ports = ( - amesim_orifice_to_line_flow - amesim_line_to_node_flow - ) - amesim_chamber_pressure = ( - amesim_value(f"press@{chamber_alias}") + AMESIM_REFERENCE_PRESSURE_PA - ) - amesim_chamber_temperature = amesim_value(f"temp@{chamber_alias}") - amesim_node_pressure = ( - amesim_value(f"press1@{node_alias}") + AMESIM_REFERENCE_PRESSURE_PA - ) - amesim_node_temperature = amesim_value(f"temp1@{node_alias}") - - python_line_to_node_flow = -snapshot.inlet_node_to_line_flow * 1.0e3 - python_orifice_to_line_flow = -snapshot.inlet_line_to_chamber_flow * 1.0e3 - python_storage_derivative = ( - snapshot.inlet_node_to_line_flow - snapshot.inlet_line_to_chamber_flow - ) * 1.0e3 - - orifice = closure.pneumatic_closure.components.inlet_orifice - - def node_to_line_flow_from_states( - *, - node_pressure_pa: float, - node_temperature_k: float, - line_pressure_pa: float, - line_temperature_k: float, - ) -> float: - pressure_difference = node_pressure_pa - line_pressure_pa - if pressure_difference == 0.0: - return 0.0 - if line.calibrated_linear_conductance is not None: - return ( - line.calibrated_linear_conductance - * pressure_difference - / sqrt(line_temperature_k) - ) - upstream_pressure = max(node_pressure_pa, line_pressure_pa) - upstream_temperature = ( - node_temperature_k if pressure_difference > 0.0 else line_temperature_k - ) - density = line.gas.density(upstream_pressure, upstream_temperature) - magnitude = line._mass_flow_for_pressure_drop( - abs(pressure_difference), - density=density, - temperature=upstream_temperature, - ) - return magnitude if pressure_difference > 0.0 else -magnitude - - def line_to_node_flow_from_states_g_s( - *, - node_pressure_pa: float, - node_temperature_k: float, - line_pressure_pa: float, - line_temperature_k: float, - ) -> float: - return -1.0e3 * node_to_line_flow_from_states( - node_pressure_pa=node_pressure_pa, - node_temperature_k=node_temperature_k, - line_pressure_pa=line_pressure_pa, - line_temperature_k=line_temperature_k, - ) - - def orifice_to_line_flow_from_states_g_s( - *, - line_pressure_pa: float, - line_temperature_k: float, - chamber_pressure_pa: float, - chamber_temperature_k: float, - ) -> float: - upstream_temperature = ( - line_temperature_k - if line_pressure_pa >= chamber_pressure_pa - else chamber_temperature_k - ) - line_to_chamber = orifice.mass_flow( - line_pressure_pa, - chamber_pressure_pa, - upstream_temperature, - ) - return -1.0e3 * line_to_chamber - - python_line_to_node_amesim_node_flow = line_to_node_flow_from_states_g_s( - node_pressure_pa=amesim_node_pressure, - node_temperature_k=amesim_node_temperature, - line_pressure_pa=snapshot.inlet_line.p, - line_temperature_k=snapshot.inlet_line.T, - ) - python_line_to_node_amesim_line_flow = line_to_node_flow_from_states_g_s( - node_pressure_pa=snapshot.inlet_node.p, - node_temperature_k=snapshot.inlet_node.T, - line_pressure_pa=amesim_line_pressure, - line_temperature_k=amesim_line_temperature, - ) - python_line_to_node_amesim_node_line_flow = line_to_node_flow_from_states_g_s( - node_pressure_pa=amesim_node_pressure, - node_temperature_k=amesim_node_temperature, - line_pressure_pa=amesim_line_pressure, - line_temperature_k=amesim_line_temperature, - ) - python_orifice_to_line_amesim_line_flow = orifice_to_line_flow_from_states_g_s( - line_pressure_pa=amesim_line_pressure, - line_temperature_k=amesim_line_temperature, - chamber_pressure_pa=snapshot.chamber.p, - chamber_temperature_k=snapshot.chamber.T, - ) - python_orifice_to_line_amesim_chamber_flow = orifice_to_line_flow_from_states_g_s( - line_pressure_pa=snapshot.inlet_line.p, - line_temperature_k=snapshot.inlet_line.T, - chamber_pressure_pa=amesim_chamber_pressure, - chamber_temperature_k=amesim_chamber_temperature, - ) - python_orifice_to_line_amesim_line_chamber_flow = ( - orifice_to_line_flow_from_states_g_s( - line_pressure_pa=amesim_line_pressure, - line_temperature_k=amesim_line_temperature, - chamber_pressure_pa=amesim_chamber_pressure, - chamber_temperature_k=amesim_chamber_temperature, - ) - ) - python_storage_amesim_boundary_states = ( - python_orifice_to_line_amesim_line_chamber_flow - - python_line_to_node_amesim_node_line_flow - ) - - amesim_line_temperature_fd = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_results.series(f"t2@{line_alias}"), - time_s=time_s, - ) - amesim_line_pressure_fd = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_results.series(f"p2@{line_alias}"), - time_s=time_s, - ) - amesim_dh1 = amesim_value(f"dh1@{line_alias}") - amesim_dh2 = amesim_value(f"dh2@{orifice_alias}") - amesim_line_mass_kg = amesim_line_mass * 1.0e-3 - amesim_storage_kg_s = amesim_storage_from_ports * 1.0e-3 - amesim_reference_temperature_k = 298.15 - amesim_mass_offset_flow = ( - line.gas.cp * amesim_reference_temperature_k - - line.gas.cv * amesim_line_temperature - ) * amesim_storage_kg_s - - def amesim_dtemp_component(flow_w: float) -> float: - return flow_w / (amesim_line_mass_kg * line.gas.cv) - - amesim_port_1_dtemp = amesim_dtemp_component(-amesim_dh1) - amesim_port_2_dtemp = amesim_dtemp_component(amesim_dh2) - amesim_mass_offset_dtemp = amesim_dtemp_component(amesim_mass_offset_flow) - amesim_reference_dtemp_sum = ( - amesim_port_1_dtemp + amesim_port_2_dtemp + amesim_mass_offset_dtemp - ) - ( - amesim_pressure_mass_component, - amesim_pressure_temperature_component, - amesim_pressure_eos_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=amesim_line_mass_kg / line.volume, - temperature_k=amesim_line_temperature, - density_derivative_kg_m3_s=amesim_storage_kg_s / line.volume, - temperature_derivative_k_s=amesim_line_temperature_fd, - ) - - python_port_1_flow = snapshot.inlet_node_to_line_flow - python_port_2_flow = -snapshot.inlet_line_to_chamber_flow - python_heat_flow = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * (line.external_temperature - snapshot.inlet_line.T) - ) - python_port_1_u = ( - snapshot.inlet_node.h / line.gas.gamma - if python_port_1_flow > 0.0 - else snapshot.inlet_line.u - ) - python_port_2_u = ( - snapshot.chamber.h / line.gas.gamma - if python_port_2_flow > 0.0 - else snapshot.inlet_line.u - ) - - def python_current_dtemp_component( - *, - mass_flow_kg_s: float, - inlet_u_j_kg: float, - ) -> float: - return ( - mass_flow_kg_s - * (inlet_u_j_kg - snapshot.inlet_line.u) - / (line.state.m * line.gas.cv) - ) - - python_port_1_dtemp = python_current_dtemp_component( - mass_flow_kg_s=python_port_1_flow, - inlet_u_j_kg=python_port_1_u, - ) - python_port_2_dtemp = python_current_dtemp_component( - mass_flow_kg_s=python_port_2_flow, - inlet_u_j_kg=python_port_2_u, - ) - python_heat_dtemp = python_heat_flow / (line.state.m * line.gas.cv) - python_current_dtemp = ( - python_port_1_dtemp + python_port_2_dtemp + python_heat_dtemp - ) - python_density_derivative = ( - (snapshot.inlet_node_to_line_flow - snapshot.inlet_line_to_chamber_flow) - / line.volume - ) - ( - python_pressure_mass_component, - python_pressure_temperature_component, - python_pressure_eos_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=line.state.m / line.volume, - temperature_k=snapshot.inlet_line.T, - density_derivative_kg_m3_s=python_density_derivative, - temperature_derivative_k_s=python_current_dtemp, - ) - - python_reference_port_1_dh = _actual_reference_enthalpy_flow( - gas=line.gas, - port_m_flow=python_port_1_flow, - connected_pressure_pa=snapshot.inlet_node.p, - connected_temperature_k=snapshot.inlet_node.T, - internal_pressure_pa=snapshot.inlet_line.p, - internal_temperature_k=snapshot.inlet_line.T, - reference_temperature_k=amesim_reference_temperature_k, - ) - python_reference_port_2_dh = _actual_reference_enthalpy_flow( - gas=line.gas, - port_m_flow=python_port_2_flow, - connected_pressure_pa=snapshot.chamber.p, - connected_temperature_k=snapshot.chamber.T, - internal_pressure_pa=snapshot.inlet_line.p, - internal_temperature_k=snapshot.inlet_line.T, - reference_temperature_k=amesim_reference_temperature_k, - ) - python_reference_mass_offset_flow = ( - line.gas.cp * amesim_reference_temperature_k - - line.gas.cv * snapshot.inlet_line.T - ) * (python_port_1_flow + python_port_2_flow) - - def python_reference_dtemp_component(flow_w: float) -> float: - return flow_w / (line.state.m * line.gas.cv) - - python_reference_port_1_dtemp = python_reference_dtemp_component( - python_reference_port_1_dh - ) - python_reference_port_2_dtemp = python_reference_dtemp_component( - python_reference_port_2_dh - ) - python_reference_mass_offset_dtemp = python_reference_dtemp_component( - python_reference_mass_offset_flow - ) - python_reference_dtemp = ( - python_reference_port_1_dtemp - + python_reference_port_2_dtemp - + python_reference_mass_offset_dtemp - + python_heat_dtemp - ) - ( - python_reference_pressure_mass_component, - python_reference_pressure_temperature_component, - python_reference_pressure_eos_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=line.state.m / line.volume, - temperature_k=snapshot.inlet_line.T, - density_derivative_kg_m3_s=python_density_derivative, - temperature_derivative_k_s=python_reference_dtemp, - ) - - python_reference_amesim_storage_mass_offset_flow = ( - line.gas.cp * amesim_reference_temperature_k - - line.gas.cv * snapshot.inlet_line.T - ) * amesim_storage_kg_s - python_reference_amesim_storage_mass_offset_dtemp = ( - python_reference_dtemp_component( - python_reference_amesim_storage_mass_offset_flow - ) - ) - python_reference_amesim_storage_dtemp = ( - python_reference_port_1_dtemp - + python_reference_port_2_dtemp - + python_reference_amesim_storage_mass_offset_dtemp - + python_heat_dtemp - ) - use_reference_rhs = bool( - getattr(closure.pneumatic_closure, "use_inlet_line_reference_rhs", False) - ) - python_active_dtemp = ( - python_reference_dtemp if use_reference_rhs else python_current_dtemp - ) - ( - python_reference_amesim_storage_pressure_mass_component, - python_reference_amesim_storage_pressure_temperature_component, - python_reference_amesim_storage_pressure_eos_sum, - ) = _pressure_derivative_components_from_density_temperature( - gas=line.gas, - density_kg_m3=line.state.m / line.volume, - temperature_k=snapshot.inlet_line.T, - density_derivative_kg_m3_s=amesim_storage_kg_s / line.volume, - temperature_derivative_k_s=python_reference_amesim_storage_dtemp, - ) - - return TestMqlPneumatic96InletLineDiagnostic( - line_alias=line_alias, - node_alias=node_alias, - orifice_alias=orifice_alias, - chamber_alias=chamber_alias, - time_s=time_s, - amesim_line_pressure_pa=amesim_line_pressure, - amesim_line_temperature_k=amesim_line_temperature, - amesim_line_mass_g=amesim_line_mass, - amesim_line_mass_derivative_fd_g_s=amesim_line_mass_fd, - amesim_line_to_node_mass_flow_g_s=amesim_line_to_node_flow, - amesim_orifice_to_line_mass_flow_g_s=amesim_orifice_to_line_flow, - amesim_storage_mass_derivative_from_ports_g_s=amesim_storage_from_ports, - amesim_storage_mass_derivative_residual_g_s=( - amesim_storage_from_ports - amesim_line_mass_fd - ), - amesim_chamber_pressure_pa=amesim_chamber_pressure, - amesim_chamber_temperature_k=amesim_chamber_temperature, - amesim_node_pressure_pa=amesim_node_pressure, - amesim_node_temperature_k=amesim_node_temperature, - amesim_line_temperature_derivative_fd_k_s=amesim_line_temperature_fd, - amesim_line_pressure_derivative_fd_pa_s=amesim_line_pressure_fd, - amesim_port_1_enthalpy_dtemp_component_k_s=amesim_port_1_dtemp, - amesim_port_2_enthalpy_dtemp_component_k_s=amesim_port_2_dtemp, - amesim_mass_offset_dtemp_component_k_s=amesim_mass_offset_dtemp, - amesim_reference_temperature_derivative_sum_k_s=( - amesim_reference_dtemp_sum - ), - amesim_reference_temperature_derivative_residual_k_s=( - amesim_reference_dtemp_sum - amesim_line_temperature_fd - ), - amesim_pressure_derivative_mass_component_pa_s=( - amesim_pressure_mass_component - ), - amesim_pressure_derivative_temperature_component_pa_s=( - amesim_pressure_temperature_component - ), - amesim_pressure_derivative_eos_sum_pa_s=amesim_pressure_eos_sum, - amesim_pressure_derivative_eos_residual_pa_s=( - amesim_pressure_eos_sum - amesim_line_pressure_fd - ), - python_line_pressure_pa=snapshot.inlet_line.p, - python_line_temperature_k=snapshot.inlet_line.T, - python_line_mass_g=line.state.m * 1.0e3, - python_line_pressure_error_pa=snapshot.inlet_line.p - amesim_line_pressure, - python_line_temperature_error_k=( - snapshot.inlet_line.T - amesim_line_temperature - ), - python_line_mass_error_g=line.state.m * 1.0e3 - amesim_line_mass, - python_line_to_node_mass_flow_g_s=python_line_to_node_flow, - python_line_to_node_mass_flow_error_g_s=( - python_line_to_node_flow - amesim_line_to_node_flow - ), - python_orifice_to_line_mass_flow_g_s=python_orifice_to_line_flow, - python_orifice_to_line_mass_flow_error_g_s=( - python_orifice_to_line_flow - amesim_orifice_to_line_flow - ), - python_storage_mass_derivative_g_s=python_storage_derivative, - python_storage_mass_derivative_error_g_s=( - python_storage_derivative - amesim_storage_from_ports - ), - python_line_to_node_amesim_node_mass_flow_g_s=( - python_line_to_node_amesim_node_flow - ), - python_line_to_node_amesim_node_mass_flow_error_g_s=( - python_line_to_node_amesim_node_flow - amesim_line_to_node_flow - ), - python_line_to_node_amesim_line_mass_flow_g_s=( - python_line_to_node_amesim_line_flow - ), - python_line_to_node_amesim_line_mass_flow_error_g_s=( - python_line_to_node_amesim_line_flow - amesim_line_to_node_flow - ), - python_line_to_node_amesim_node_line_mass_flow_g_s=( - python_line_to_node_amesim_node_line_flow - ), - python_line_to_node_amesim_node_line_mass_flow_error_g_s=( - python_line_to_node_amesim_node_line_flow - amesim_line_to_node_flow - ), - python_orifice_to_line_amesim_line_mass_flow_g_s=( - python_orifice_to_line_amesim_line_flow - ), - python_orifice_to_line_amesim_line_mass_flow_error_g_s=( - python_orifice_to_line_amesim_line_flow - amesim_orifice_to_line_flow - ), - python_orifice_to_line_amesim_chamber_mass_flow_g_s=( - python_orifice_to_line_amesim_chamber_flow - ), - python_orifice_to_line_amesim_chamber_mass_flow_error_g_s=( - python_orifice_to_line_amesim_chamber_flow - amesim_orifice_to_line_flow - ), - python_orifice_to_line_amesim_line_chamber_mass_flow_g_s=( - python_orifice_to_line_amesim_line_chamber_flow - ), - python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s=( - python_orifice_to_line_amesim_line_chamber_flow - - amesim_orifice_to_line_flow - ), - python_storage_amesim_boundary_states_mass_derivative_g_s=( - python_storage_amesim_boundary_states - ), - python_storage_amesim_boundary_states_mass_derivative_error_g_s=( - python_storage_amesim_boundary_states - amesim_storage_from_ports - ), - python_chamber_pressure_pa=snapshot.chamber.p, - python_chamber_temperature_k=snapshot.chamber.T, - python_current_temperature_derivative_k_s=python_current_dtemp, - python_port_1_current_dtemp_component_k_s=python_port_1_dtemp, - python_port_2_current_dtemp_component_k_s=python_port_2_dtemp, - python_heat_dtemp_component_k_s=python_heat_dtemp, - python_temperature_derivative_error_k_s=( - python_current_dtemp - amesim_line_temperature_fd - ), - python_pressure_derivative_mass_component_pa_s=( - python_pressure_mass_component - ), - python_pressure_derivative_temperature_component_pa_s=( - python_pressure_temperature_component - ), - python_pressure_derivative_eos_sum_pa_s=python_pressure_eos_sum, - python_pressure_derivative_eos_error_pa_s=( - python_pressure_eos_sum - amesim_line_pressure_fd - ), - python_reference_port_1_enthalpy_flow_w=python_reference_port_1_dh, - python_reference_port_2_enthalpy_flow_w=python_reference_port_2_dh, - python_reference_port_1_dtemp_component_k_s=( - python_reference_port_1_dtemp - ), - python_reference_port_2_dtemp_component_k_s=( - python_reference_port_2_dtemp - ), - python_reference_mass_offset_dtemp_component_k_s=( - python_reference_mass_offset_dtemp - ), - python_reference_temperature_derivative_k_s=python_reference_dtemp, - python_reference_temperature_derivative_error_k_s=( - python_reference_dtemp - amesim_line_temperature_fd - ), - python_reference_pressure_derivative_mass_component_pa_s=( - python_reference_pressure_mass_component - ), - python_reference_pressure_derivative_temperature_component_pa_s=( - python_reference_pressure_temperature_component - ), - python_reference_pressure_derivative_eos_sum_pa_s=( - python_reference_pressure_eos_sum - ), - python_reference_pressure_derivative_eos_error_pa_s=( - python_reference_pressure_eos_sum - amesim_line_pressure_fd - ), - python_reference_amesim_storage_mass_offset_dtemp_component_k_s=( - python_reference_amesim_storage_mass_offset_dtemp - ), - python_reference_amesim_storage_temperature_derivative_k_s=( - python_reference_amesim_storage_dtemp - ), - python_reference_amesim_storage_temperature_derivative_error_k_s=( - python_reference_amesim_storage_dtemp - amesim_line_temperature_fd - ), - python_reference_amesim_storage_pressure_derivative_mass_component_pa_s=( - python_reference_amesim_storage_pressure_mass_component - ), - python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s=( - python_reference_amesim_storage_pressure_temperature_component - ), - python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s=( - python_reference_amesim_storage_pressure_eos_sum - ), - python_reference_amesim_storage_pressure_derivative_eos_error_pa_s=( - python_reference_amesim_storage_pressure_eos_sum - amesim_line_pressure_fd - ), - python_active_temperature_derivative_k_s=python_active_dtemp, - python_active_temperature_derivative_error_k_s=( - python_active_dtemp - amesim_line_temperature_fd - ), - python_active_pressure_derivative_eos_sum_pa_s=( - python_reference_pressure_eos_sum - if use_reference_rhs - else python_pressure_eos_sum - ), - python_active_pressure_derivative_eos_error_pa_s=( - (python_reference_pressure_eos_sum if use_reference_rhs else python_pressure_eos_sum) - - amesim_line_pressure_fd - ), - python_chamber_pressure_error_pa=( - snapshot.chamber.p - amesim_chamber_pressure - ), - python_chamber_temperature_error_k=( - snapshot.chamber.T - amesim_chamber_temperature - ), - ) - - -def _pnch012_energy_equation_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - state_vector: list[float], - rhs_diagnostic: TestMqlVariableChamberRhsDiagnostic, - chamber_alias: str, - piston_alias: str, - line_alias: str, - time_s: float, -) -> TestMqlPnch012EnergyEquationDiagnostic: - if chamber_alias != "pn_c1_8" or piston_alias != "pn_brp2_8": - raise KeyError(f"Unsupported PNCH012 energy diagnostic: {chamber_alias}") - if line_alias != "pneumatic_69": - raise KeyError(f"Unsupported PNCH012 line diagnostic: {line_alias}") - - snapshot = closure.snapshot_at(time_s, state_vector).pneumatic - chamber = closure.pneumatic_closure.components.p4_port3_remote_primary_chamber - chamber_properties = snapshot.p4_port3_remote_primary_chamber - reference_temperature_k = 298.15 - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_dh1 = amesim_value(f"dh1@{line_alias}") - amesim_dm1_g_s = amesim_value(f"dm1@{line_alias}") - amesim_chamber_temperature = amesim_value(f"temp@{chamber_alias}") - amesim_chamber_pressure_abs = ( - amesim_value(f"press@{chamber_alias}") + AMESIM_REFERENCE_PRESSURE_PA - ) - amesim_mgas_kg = amesim_value(f"mgas1@{chamber_alias}") * 1.0e-3 - amesim_mass_derivative_fd_g_s = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_results.series(f"mgas1@{chamber_alias}"), - time_s=time_s, - ) - amesim_temperature_derivative_fd = _series_finite_difference_at( - times=amesim_results.times, - values=amesim_results.series(f"temp@{chamber_alias}"), - time_s=time_s, - ) - amesim_volume_rate_fd = ( - _series_finite_difference_at( - times=amesim_results.times, - values=amesim_results.series(f"vol@{chamber_alias}"), - time_s=time_s, - ) - * 1.0e-6 - ) - amesim_piston_volume_rate = amesim_value(f"vvol1@{piston_alias}") * (1.0 / 60000.0) - amesim_heat_flow = ( - chamber.heat_transfer_coefficient - * chamber.heat_transfer_area - * (chamber.external_temperature - amesim_chamber_temperature) - ) - - def reference_pn2vol_dtemp(volume_rate_m3_s: float) -> float: - mass_flow_kg_s = amesim_dm1_g_s * 1.0e-3 - reference_offset_flow_w = ( - chamber.gas.cp * reference_temperature_k - - chamber.gas.cv * amesim_chamber_temperature - ) * mass_flow_kg_s - return ( - amesim_dh1 - + reference_offset_flow_w - + amesim_heat_flow - - amesim_chamber_pressure_abs * volume_rate_m3_s - ) / (amesim_mgas_kg * chamber.gas.cv) - - dtemp_from_fd_volume = reference_pn2vol_dtemp(amesim_volume_rate_fd) - dtemp_from_piston_volume = reference_pn2vol_dtemp(amesim_piston_volume_rate) - python_current_dtemp = ( - rhs_diagnostic.energy_derivative_w - - chamber_properties.u * rhs_diagnostic.mass_derivative_kg_s - ) / (chamber.state.m * chamber.gas.cv) - - return TestMqlPnch012EnergyEquationDiagnostic( - chamber_alias=chamber_alias, - piston_alias=piston_alias, - line_alias=line_alias, - time_s=time_s, - amesim_port_1_enthalpy_flow_w=amesim_dh1, - amesim_port_1_mass_flow_g_s=amesim_dm1_g_s, - amesim_chamber_mass_derivative_fd_g_s=amesim_mass_derivative_fd_g_s, - amesim_chamber_mass_derivative_residual_g_s=( - amesim_dm1_g_s - amesim_mass_derivative_fd_g_s - ), - amesim_chamber_temperature_derivative_fd_k_s=amesim_temperature_derivative_fd, - amesim_chamber_volume_rate_fd_m3_s=amesim_volume_rate_fd, - amesim_piston_volume_rate_m3_s=amesim_piston_volume_rate, - amesim_heat_flow_w=amesim_heat_flow, - amesim_boundary_work_fd_volume_w=( - -amesim_chamber_pressure_abs * amesim_volume_rate_fd - ), - amesim_pn2vol_reference_dtemp_fd_volume_k_s=dtemp_from_fd_volume, - amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s=( - dtemp_from_fd_volume - amesim_temperature_derivative_fd - ), - amesim_pn2vol_reference_dtemp_piston_volume_k_s=dtemp_from_piston_volume, - amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s=( - dtemp_from_piston_volume - amesim_temperature_derivative_fd - ), - python_port_a_mass_flow_kg_s=rhs_diagnostic.port_a_mass_flow_kg_s, - python_current_energy_derivative_w=rhs_diagnostic.energy_derivative_w, - python_current_chamber_temperature_derivative_k_s=python_current_dtemp, - reference_temperature_k=reference_temperature_k, - ) - -def _pnvo_flow_parameter_diagnostic( - *, - closure: object, - amesim_results: AmesimResults, - state_vector: list[float], - orifice_alias: str, - time_s: float, -) -> TestMqlPnvoFlowParameterDiagnostic: - if orifice_alias != "pn_morifice_1": - raise KeyError(f"Unsupported PNVO flow parameter diagnostic: {orifice_alias}") - snapshot = closure.snapshot_at(time_s, state_vector).pneumatic - orifice = closure.pneumatic_closure.components.p4_port3_remote_orifice - if orifice.name != orifice_alias: - raise KeyError(f"Unexpected PNVO diagnostic orifice: {orifice.name}") - line_properties = snapshot.p4_port3_remote_orifice_line_port_2 - boundary_properties = snapshot.p4_port3_remote_primary_line - upstream_properties = ( - line_properties if line_properties.p >= boundary_properties.p else boundary_properties - ) - python_mass_flow_kg_s = snapshot.p4_port3_remote_orifice_to_node_flow - denominator = ( - orifice.flow_coefficient * orifice.effective_area * upstream_properties.p - ) - python_cm = ( - abs(python_mass_flow_kg_s) * sqrt(upstream_properties.T) / denominator - if denominator > 0.0 and upstream_properties.T > 0.0 - else 0.0 - ) - - def amesim_value(data_path: str) -> float: - return interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - time_s, - ) - - amesim_cm = amesim_value(f"cm@{orifice_alias}") - cm_ratio = python_cm / amesim_cm if amesim_cm != 0.0 else 0.0 - return TestMqlPnvoFlowParameterDiagnostic( - orifice_alias=orifice_alias, - time_s=time_s, - amesim_cm=amesim_cm, - amesim_dm2_g_s=amesim_value(f"dm2@{orifice_alias}"), - amesim_opening=amesim_value(f"xv@{orifice_alias}"), - amesim_gas_velocity_m_s=amesim_value(f"gasvel@{orifice_alias}"), - python_opening=orifice.opening, - python_flow_coefficient=orifice.flow_coefficient, - python_effective_area_m2=orifice.effective_area, - python_line_pressure_pa=line_properties.p, - python_boundary_pressure_pa=boundary_properties.p, - python_upstream_pressure_pa=upstream_properties.p, - python_upstream_temperature_k=upstream_properties.T, - python_mass_flow_kg_s=python_mass_flow_kg_s, - python_cm=python_cm, - python_to_amesim_cm_ratio=cm_ratio, - ) - - -def run_test_mql_pnvo_event_window_diagnostic( - config: TestMqlFullStateComparisonScriptConfig | None = None, - *, - orifice_alias: str = "pn_morifice_1", - final_time_s: float = 0.05, -) -> TestMqlPnvoEventWindowDiagnostic: - config = config or TestMqlFullStateComparisonScriptConfig() - system = TestMqlSystem(archive_path=config.paths.archive_path) - control = system.pneumatic_assembly.variable_orifice_controls[orifice_alias] - event_time_s = control.step.step_time_s - if final_time_s <= event_time_s: - raise ValueError("final_time_s must be greater than the PNVO event time") - spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] - closure_kwargs = { - "inlet_node_pressure_pa": config.execution.inlet_node_pressure_pa, - "resistance_boundary_pressure_pa": ( - config.execution.resistance_boundary_pressure_pa - ), - "inlet_node_temperature_k": config.execution.inlet_node_temperature_k, - "resistance_boundary_temperature_k": ( - config.execution.resistance_boundary_temperature_k - ), - "use_pneumatic_96_reference_rhs": ( - config.execution.use_pneumatic_96_reference_rhs - ), - "use_pneumatic_69_reference_rhs": ( - config.execution.use_pneumatic_69_reference_rhs - ), - } - closure = system.full_state_closure_from_spec(spec, **closure_kwargs) - state_vector = closure.initial_state_vector() - solver_template = config.execution.solver - segment_templates = ( - SolveIVPConfig( - t_start=solver_template.t_start, - t_stop=nextafter(event_time_s, 0.0), - method=solver_template.method, - rtol=solver_template.rtol, - atol=solver_template.atol, - max_step=solver_template.max_step, - ), - SolveIVPConfig( - t_start=event_time_s, - t_stop=0.040001, - method="Radau", - rtol=solver_template.rtol, - atol=solver_template.atol, - max_step=1.0e-7, - first_step=1.0e-10, - ), - SolveIVPConfig( - t_start=0.040001, - t_stop=0.04001, - method="Radau", - rtol=solver_template.rtol, - atol=solver_template.atol, - max_step=1.0e-6, - ), - SolveIVPConfig( - t_start=0.04001, - t_stop=0.0401, - method="Radau", - rtol=solver_template.rtol, - atol=solver_template.atol, - max_step=1.0e-5, - ), - SolveIVPConfig( - t_start=0.0401, - t_stop=0.041, - method="Radau", - rtol=solver_template.rtol, - atol=solver_template.atol, - max_step=1.0e-5, - ), - SolveIVPConfig( - t_start=0.041, - t_stop=0.048, - method="BDF", - rtol=solver_template.rtol, - atol=solver_template.atol, - max_step=1.0e-5, - ), - SolveIVPConfig( - t_start=0.048, - t_stop=0.05, - method="BDF", - rtol=1.0e-5, - atol=1.0e-8, - max_step=1.0e-5, - ), - ) - segments = tuple( - replace(segment, t_stop=min(segment.t_stop, final_time_s)) - for segment in segment_templates - if segment.t_start < final_time_s - ) - data_paths = ( - "press@pn_c1_8", - "temp@pn_c1_8", - "vol@pn_c1_8", - "mgas1@pn_c1_8", - "p2@pneumatic_69", - "t2@pneumatic_69", - "mgas@pneumatic_69", - "re@pneumatic_69", - "v@pneumatic_69", - "ff@pneumatic_69", - "dm1@pneumatic_69", - f"xv@{orifice_alias}", - f"dm2@{orifice_alias}", - ) - sample_times = tuple( - sorted( - { - sample_time - for sample_time in ( - nextafter(event_time_s, 0.0), - 0.0401, - 0.0402, - 0.041, - 0.042, - 0.045, - 0.048, - final_time_s, - ) - if sample_time <= final_time_s - } - ) - ) - state_vector_by_sample_time: dict[float, list[float]] = {} - segment_diagnostics: list[TestMqlPnvoEventWindowSegmentDiagnostic] = [] - for segment in segments: - rhs_evaluations = 0 - segment_sample_times = tuple( - sample_time - for sample_time in sample_times - if segment.t_start <= sample_time <= segment.t_stop - ) - t_eval = tuple( - sorted({segment.t_start, segment.t_stop, *segment_sample_times}) - ) - - def rhs(time_s, values): - nonlocal rhs_evaluations - rhs_evaluations += 1 - return closure.rhs_at(time_s, values) - - solution = integrate_ode( - rhs=rhs, - initial_state=state_vector, - config=segment, - t_eval=list(t_eval), - ) - segment_diagnostics.append( - TestMqlPnvoEventWindowSegmentDiagnostic( - t_start=segment.t_start, - t_stop=segment.t_stop, - method=segment.method, - rtol=segment.rtol, - atol=segment.atol, - max_step=segment.max_step, - rhs_evaluations=rhs_evaluations, - success=bool(solution.success), - message=str(solution.message), - ) - ) - for sample_time in segment_sample_times: - if sample_time in solution.t: - sample_index = list(solution.t).index(sample_time) - state_vector_by_sample_time[sample_time] = [ - row[sample_index] for row in solution.y - ] - state_vector = [row[-1] for row in solution.y] - if not solution.success: - break - amesim_results = load_test_mql_amesim_results( - config.paths.resolved_amesim_results_archive_path, - time_stop_s=final_time_s, - ) - sample_diagnostics = [] - for sample_time in sample_times: - if sample_time not in state_vector_by_sample_time: - continue - python_sample_values = closure.data_path_values( - time_s=sample_time, - state_vector=state_vector_by_sample_time[sample_time], - data_paths=data_paths, - ) - amesim_sample_values = { - data_path: interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - sample_time, - ) - for data_path in data_paths - } - pnl0001_rhs_diagnostics = ( - closure.pnl0001_line_rhs_diagnostic( - line_alias="pneumatic_69", - state_vector=state_vector_by_sample_time[sample_time], - time_s=sample_time, - ), - ) - pnl0001_pressure_loss_diagnostics = ( - _pnl0001_pressure_loss_calibration_diagnostic( - closure=closure, - amesim_results=amesim_results, - python_values_by_data_path=python_sample_values, - line_alias="pneumatic_69", - chamber_alias="pn_c1_8", - time_s=sample_time, - ), - ) - pnvo_flow_parameter_diagnostics = ( - _pnvo_flow_parameter_diagnostic( - closure=closure, - amesim_results=amesim_results, - state_vector=state_vector_by_sample_time[sample_time], - orifice_alias=orifice_alias, - time_s=sample_time, - ), - ) - pnl0001_energy_flow_diagnostics = ( - _pnl0001_energy_flow_diagnostic( - closure=closure, - amesim_results=amesim_results, - state_vector=state_vector_by_sample_time[sample_time], - rhs_diagnostic=pnl0001_rhs_diagnostics[0], - line_alias="pneumatic_69", - chamber_alias="pn_c1_8", - node_alias="pnnode4_16", - time_s=sample_time, - ), - ) - p4_node_enthalpy_breakdown_diagnostics = ( - _p4_node_enthalpy_breakdown_diagnostic( - closure=closure, - amesim_results=amesim_results, - state_vector=state_vector_by_sample_time[sample_time], - node_alias="pnnode4_16", - time_s=sample_time, - ), - ) - pnvo_upstream_enthalpy_diagnostics = ( - _pnvo_upstream_enthalpy_diagnostic( - closure=closure, - amesim_results=amesim_results, - state_vector=state_vector_by_sample_time[sample_time], - orifice_alias=orifice_alias, - upstream_line_alias="pneumatic_87", - downstream_node_alias="pnnode4_16", - time_s=sample_time, - ), - ) - pnl0003_energy_diagnostics = ( - _pnl0003_energy_diagnostic( - closure=closure, - amesim_results=amesim_results, - state_vector=state_vector_by_sample_time[sample_time], - line_alias="pneumatic_87", - time_s=sample_time, - ), - ) - pneumatic_96_inlet_line_diagnostics = ( - _pneumatic_96_inlet_line_diagnostic( - closure=closure, - amesim_results=amesim_results, - state_vector=state_vector_by_sample_time[sample_time], - line_alias="pneumatic_96", - node_alias="pn_node3_8", - orifice_alias="pn_orifice_18", - chamber_alias="pn_general_chamber", - time_s=sample_time, - ), - ) - pnch012_rhs_diagnostics = ( - closure.variable_chamber_rhs_diagnostic( - chamber_alias="pn_c1_8", - state_vector=state_vector_by_sample_time[sample_time], - time_s=sample_time, - ), - ) - pnch012_energy_equation_diagnostics = ( - _pnch012_energy_equation_diagnostic( - closure=closure, - amesim_results=amesim_results, - state_vector=state_vector_by_sample_time[sample_time], - rhs_diagnostic=pnch012_rhs_diagnostics[0], - chamber_alias="pn_c1_8", - piston_alias="pn_brp2_8", - line_alias="pneumatic_69", - time_s=sample_time, - ), - ) - ( - amesim_sample_left_time_s, - amesim_sample_right_time_s, - ) = _series_sample_bracket(times=amesim_results.times, time_s=sample_time) - sample_diagnostics.append( - TestMqlPnvoEventWindowSampleDiagnostic( - time_s=sample_time, - data_paths=data_paths, - python_values_by_data_path=python_sample_values, - amesim_values_by_data_path=amesim_sample_values, - amesim_sample_left_time_s=amesim_sample_left_time_s, - amesim_sample_right_time_s=amesim_sample_right_time_s, - pnl0001_rhs_diagnostics=pnl0001_rhs_diagnostics, - pnl0001_pressure_loss_diagnostics=pnl0001_pressure_loss_diagnostics, - pnvo_flow_parameter_diagnostics=pnvo_flow_parameter_diagnostics, - pnl0001_energy_flow_diagnostics=pnl0001_energy_flow_diagnostics, - p4_node_enthalpy_breakdown_diagnostics=( - p4_node_enthalpy_breakdown_diagnostics - ), - pnvo_upstream_enthalpy_diagnostics=( - pnvo_upstream_enthalpy_diagnostics - ), - pnl0003_energy_diagnostics=pnl0003_energy_diagnostics, - pneumatic_96_inlet_line_diagnostics=( - pneumatic_96_inlet_line_diagnostics - ), - pnch012_energy_equation_diagnostics=( - pnch012_energy_equation_diagnostics - ), - ) - ) - python_values = closure.data_path_values( - time_s=final_time_s, - state_vector=state_vector, - data_paths=data_paths, - ) - amesim_values = { - data_path: interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - final_time_s, - ) - for data_path in data_paths - } - return TestMqlPnvoEventWindowDiagnostic( - orifice_alias=orifice_alias, - event_time_s=event_time_s, - final_time_s=final_time_s, - data_paths=data_paths, - segment_diagnostics=tuple(segment_diagnostics), - sample_diagnostics=tuple(sample_diagnostics), - python_values_by_data_path=python_values, - amesim_values_by_data_path=amesim_values, - ) - - - -def _event_window_config_with_pneumatic_96_reference_rhs( - config: TestMqlFullStateComparisonScriptConfig | None, - *, - enabled: bool, -) -> TestMqlFullStateComparisonScriptConfig: - base = config or TestMqlFullStateComparisonScriptConfig() - return replace( - base, - execution=replace( - base.execution, - use_pneumatic_96_reference_rhs=enabled, - ), - ) - - -def _pnvo_event_window_sample_by_time( - diagnostic: TestMqlPnvoEventWindowDiagnostic, -) -> dict[float, TestMqlPnvoEventWindowSampleDiagnostic]: - return {sample.time_s: sample for sample in diagnostic.sample_diagnostics} - - -def _append_metric_comparison( - comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], - *, - time_s: float, - metric_name: str, - default_abs_error: float, - candidate_abs_error: float, -) -> None: - comparisons.append( - TestMqlPnvoEventWindowCandidateMetricComparison( - time_s=time_s, - metric_name=metric_name, - default_abs_error=default_abs_error, - candidate_abs_error=candidate_abs_error, - ) - ) - - -def _append_pneumatic_96_candidate_metric_comparisons( - comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], - *, - time_s: float, - default_sample: TestMqlPnvoEventWindowSampleDiagnostic, - candidate_sample: TestMqlPnvoEventWindowSampleDiagnostic, -) -> None: - if ( - not default_sample.pneumatic_96_inlet_line_diagnostics - or not candidate_sample.pneumatic_96_inlet_line_diagnostics - ): - return - default_line = default_sample.pneumatic_96_inlet_line_diagnostics[0] - candidate_line = candidate_sample.pneumatic_96_inlet_line_diagnostics[0] - for metric_name, default_value, candidate_value in ( - ( - "p96:p2_abs_error_pa", - default_line.python_line_pressure_error_pa, - candidate_line.python_line_pressure_error_pa, - ), - ( - "p96:t2_abs_error_k", - default_line.python_line_temperature_error_k, - candidate_line.python_line_temperature_error_k, - ), - ( - "p96:storage_dm_abs_error_g_s", - default_line.python_storage_mass_derivative_error_g_s, - candidate_line.python_storage_mass_derivative_error_g_s, - ), - ( - "p96:active_dT_abs_error_k_s", - default_line.python_active_temperature_derivative_error_k_s, - candidate_line.python_active_temperature_derivative_error_k_s, - ), - ( - "p96:active_dP_abs_error_pa_s", - default_line.python_active_pressure_derivative_eos_error_pa_s, - candidate_line.python_active_pressure_derivative_eos_error_pa_s, - ), - ): - _append_metric_comparison( - comparisons, - time_s=time_s, - metric_name=metric_name, - default_abs_error=abs(default_value), - candidate_abs_error=abs(candidate_value), - ) - - -def _append_pneumatic_87_candidate_metric_comparisons( - comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison], - *, - time_s: float, - default_sample: TestMqlPnvoEventWindowSampleDiagnostic, - candidate_sample: TestMqlPnvoEventWindowSampleDiagnostic, -) -> None: - if ( - not default_sample.pnl0003_energy_diagnostics - or not candidate_sample.pnl0003_energy_diagnostics - ): - return - default_line = default_sample.pnl0003_energy_diagnostics[0] - candidate_line = candidate_sample.pnl0003_energy_diagnostics[0] - for metric_name, default_value, candidate_value in ( - ( - "p87:node_to_line_dm_abs_error_g_s", - default_line.python_pn3_node_to_line_mass_flow_error_g_s, - candidate_line.python_pn3_node_to_line_mass_flow_error_g_s, - ), - ( - "p87:center_dm_abs_error_g_s", - default_line.amesim_center_flow_python_sign_error_g_s, - candidate_line.amesim_center_flow_python_sign_error_g_s, - ), - ( - "p87:sdm1_abs_error_g_s", - default_line.python_port_1_storage_mass_derivative_error_g_s, - candidate_line.python_port_1_storage_mass_derivative_error_g_s, - ), - ( - "p87:sdm2_abs_error_g_s", - default_line.python_port_2_storage_mass_derivative_error_g_s, - candidate_line.python_port_2_storage_mass_derivative_error_g_s, - ), - ( - "p87:t2_abs_error_k", - default_line.python_port_2_temperature_error_to_amesim_k, - candidate_line.python_port_2_temperature_error_to_amesim_k, - ), - ): - _append_metric_comparison( - comparisons, - time_s=time_s, - metric_name=metric_name, - default_abs_error=abs(default_value), - candidate_abs_error=abs(candidate_value), - ) - - -def _pnvo_event_window_candidate_metric_comparisons( - default_diagnostic: TestMqlPnvoEventWindowDiagnostic, - candidate_diagnostic: TestMqlPnvoEventWindowDiagnostic, -) -> tuple[TestMqlPnvoEventWindowCandidateMetricComparison, ...]: - candidate_samples = _pnvo_event_window_sample_by_time(candidate_diagnostic) - comparisons: list[TestMqlPnvoEventWindowCandidateMetricComparison] = [] - for default_sample in default_diagnostic.sample_diagnostics: - candidate_sample = candidate_samples.get(default_sample.time_s) - if candidate_sample is None: - continue - for data_path in default_sample.data_paths: - _append_metric_comparison( - comparisons, - time_s=default_sample.time_s, - metric_name=f"path:{data_path}", - default_abs_error=default_sample.abs_error(data_path), - candidate_abs_error=candidate_sample.abs_error(data_path), - ) - _append_pneumatic_96_candidate_metric_comparisons( - comparisons, - time_s=default_sample.time_s, - default_sample=default_sample, - candidate_sample=candidate_sample, - ) - _append_pneumatic_87_candidate_metric_comparisons( - comparisons, - time_s=default_sample.time_s, - default_sample=default_sample, - candidate_sample=candidate_sample, - ) - return tuple(comparisons) - - -def run_test_mql_pnvo_event_window_candidate_comparison( - config: TestMqlFullStateComparisonScriptConfig | None = None, - *, - orifice_alias: str = "pn_morifice_1", - final_time_s: float = 0.041, -) -> TestMqlPnvoEventWindowCandidateComparisonDiagnostic: - default_diagnostic = run_test_mql_pnvo_event_window_diagnostic( - _event_window_config_with_pneumatic_96_reference_rhs( - config, - enabled=False, - ), - orifice_alias=orifice_alias, - final_time_s=final_time_s, - ) - candidate_diagnostic = run_test_mql_pnvo_event_window_diagnostic( - _event_window_config_with_pneumatic_96_reference_rhs( - config, - enabled=True, - ), - orifice_alias=orifice_alias, - final_time_s=final_time_s, - ) - return TestMqlPnvoEventWindowCandidateComparisonDiagnostic( - orifice_alias=orifice_alias, - event_time_s=default_diagnostic.event_time_s, - final_time_s=default_diagnostic.final_time_s, - default_diagnostic=default_diagnostic, - candidate_diagnostic=candidate_diagnostic, - metric_comparisons=_pnvo_event_window_candidate_metric_comparisons( - default_diagnostic, - candidate_diagnostic, - ), - ) - - -def format_test_mql_pnvo_event_window_summary( - diagnostic: TestMqlPnvoEventWindowDiagnostic, -) -> str: - lines = [ - "Model: test_mql", - f"Mode: PNVO event window diagnostic ({diagnostic.orifice_alias})", - f"Event time: {diagnostic.event_time_s}", - f"Final time: {diagnostic.final_time_s}", - "Segments:", - ] - for segment in diagnostic.segment_diagnostics: - lines.append( - f" - {segment.t_start} -> {segment.t_stop}: " - f"method={segment.method}, rtol={segment.rtol}, " - f"atol={segment.atol}, max_step={segment.max_step}, " - f"rhs={segment.rhs_evaluations}, success={segment.success}" - ) - if diagnostic.sample_diagnostics: - lines.append("Sample comparisons:") - for sample in diagnostic.sample_diagnostics: - if sample.amesim_values_are_interpolated: - amesim_sample_source = ( - "linear-interpolation@" - f"{sample.amesim_sample_left_time_s}" - f"..{sample.amesim_sample_right_time_s}" - ) - else: - amesim_sample_source = f"exact@{sample.amesim_sample_left_time_s}" - lines.append( - f" t={sample.time_s} (amesim={amesim_sample_source})" - ) - for data_path in sample.data_paths: - lines.append( - f" - {data_path}: " - f"python={sample.python_values_by_data_path[data_path]}, " - f"amesim={sample.amesim_values_by_data_path[data_path]}, " - f"abs_error={sample.abs_error(data_path)}" - ) - for rhs in sample.pnl0001_rhs_diagnostics: - lines.append( - f" - rhs@{rhs.line_alias}: " - f"chamber_to_line={rhs.chamber_to_line_flow_kg_s}, " - f"node_to_line={rhs.node_to_line_flow_kg_s}, " - f"dm_dt={rhs.mass_derivative_kg_s}, " - f"dU_dt={rhs.energy_derivative_w}" - ) - for pressure_loss in sample.pnl0001_pressure_loss_diagnostics: - lines.append( - f" - pressure_loss@{pressure_loss.line_alias}: " - f"cm={pressure_loss.amesim_cm}, " - f"amesim_dm1={pressure_loss.amesim_dm1_g_s}, " - f"amesim_dp={pressure_loss.amesim_pressure_drop_pa}, " - f"darcy_dp={pressure_loss.current_darcy_pressure_drop_pa}, " - f"dp_multiplier={pressure_loss.pressure_drop_multiplier}, " - f"candidate_pn2pipefr_dm1=" - f"{pressure_loss.candidate_pn2pipefr_dm1_g_s}, " - f"candidate_pn2pipefr_ratio=" - f"{pressure_loss.candidate_pn2pipefr_to_amesim_dm1_ratio}, " - f"amesim_linear_k=" - f"{pressure_loss.amesim_linear_conductance_kg_s_sqrt_k_per_pa}, " - f"python_linear_k=" - f"{pressure_loss.python_linear_conductance_kg_s_sqrt_k_per_pa}, " - f"linear_k_ratio=" - f"{pressure_loss.python_to_amesim_linear_conductance_ratio}" - ) - for flow_parameter in sample.pnvo_flow_parameter_diagnostics: - lines.append( - f" - flow_parameter@{flow_parameter.orifice_alias}: " - f"amesim_cm={flow_parameter.amesim_cm}, " - f"python_cm={flow_parameter.python_cm}, " - f"cm_ratio={flow_parameter.python_to_amesim_cm_ratio}, " - f"amesim_dm2={flow_parameter.amesim_dm2_g_s}, " - f"python_m={flow_parameter.python_mass_flow_kg_s}, " - f"opening={flow_parameter.python_opening}, " - f"effective_area={flow_parameter.python_effective_area_m2}, " - f"upstream_p={flow_parameter.python_upstream_pressure_pa}, " - f"upstream_t={flow_parameter.python_upstream_temperature_k}, " - f"amesim_gasvel={flow_parameter.amesim_gas_velocity_m_s}" - ) - for pnvo_enthalpy in sample.pnvo_upstream_enthalpy_diagnostics: - lines.append( - f" - pnvo_upstream_enthalpy@{pnvo_enthalpy.orifice_alias}: " - f"amesim_dh2=" - f"{pnvo_enthalpy.amesim_orifice_port_2_enthalpy_flow_w}, " - f"amesim_dm2=" - f"{pnvo_enthalpy.amesim_orifice_port_2_mass_flow_g_s}, " - f"amesim_dh3=" - f"{pnvo_enthalpy.amesim_orifice_port_3_enthalpy_flow_w}, " - f"amesim_dm3=" - f"{pnvo_enthalpy.amesim_orifice_port_3_mass_flow_g_s}, " - f"amesim_line_dhctr=" - f"{pnvo_enthalpy.amesim_upstream_line_center_enthalpy_flow_w}, " - f"amesim_line_dmctr=" - f"{pnvo_enthalpy.amesim_upstream_line_center_mass_flow_g_s}, " - f"amesim_line_t2=" - f"{pnvo_enthalpy.amesim_upstream_line_port_2_temperature_k}, " - f"amesim_line_p2_abs=" - f"{pnvo_enthalpy.amesim_upstream_line_port_2_pressure_pa}, " - f"amesim_implied_t2=" - f"{pnvo_enthalpy.amesim_orifice_port_2_implied_reference_temperature_k}, " - f"amesim_implied_t2_delta=" - f"{pnvo_enthalpy.amesim_orifice_port_2_implied_temperature_delta_to_line_k}, " - f"amesim_ref_dh2=" - f"{pnvo_enthalpy.amesim_upstream_line_port_2_reference_enthalpy_flow_w}, " - f"amesim_ref_dh2_error=" - f"{pnvo_enthalpy.amesim_upstream_line_port_2_reference_enthalpy_error_w}, " - f"amesim_real_ref_dh2=" - f"{pnvo_enthalpy.amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w}, " - f"amesim_real_ref_dh2_error=" - f"{pnvo_enthalpy.amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w}, " - f"python_flow=" - f"{pnvo_enthalpy.python_orifice_to_node_flow_kg_s}, " - f"python_line_t2=" - f"{pnvo_enthalpy.python_upstream_line_port_2_temperature_k}, " - f"python_line_p2_abs=" - f"{pnvo_enthalpy.python_upstream_line_port_2_pressure_pa}, " - f"python_current_dh2=" - f"{pnvo_enthalpy.python_current_port_2_enthalpy_flow_w}, " - f"python_ref_dh2=" - f"{pnvo_enthalpy.python_reference_port_2_enthalpy_flow_w}, " - f"python_real_ref_dh2=" - f"{pnvo_enthalpy.python_real_gas_reference_port_2_enthalpy_flow_w}, " - f"python_current_dh2_error=" - f"{pnvo_enthalpy.python_current_port_2_enthalpy_error_w}, " - f"python_ref_dh2_error=" - f"{pnvo_enthalpy.python_reference_port_2_enthalpy_error_w}, " - f"python_real_ref_dh2_error=" - f"{pnvo_enthalpy.python_real_gas_reference_port_2_enthalpy_error_w}" - ) - for inlet_line in sample.pneumatic_96_inlet_line_diagnostics: - lines.append( - f" - inlet_line@{inlet_line.line_alias}: " - f"node={inlet_line.node_alias}, " - f"orifice={inlet_line.orifice_alias}, " - f"chamber={inlet_line.chamber_alias}, " - f"amesim_p2_abs={inlet_line.amesim_line_pressure_pa}, " - f"amesim_t2={inlet_line.amesim_line_temperature_k}, " - f"amesim_mgas={inlet_line.amesim_line_mass_g}, " - f"amesim_fd_dmgas={inlet_line.amesim_line_mass_derivative_fd_g_s}, " - f"amesim_line_to_node_dm1={inlet_line.amesim_line_to_node_mass_flow_g_s}, " - f"amesim_orifice_to_line_dm2={inlet_line.amesim_orifice_to_line_mass_flow_g_s}, " - f"amesim_storage_dm_ports={inlet_line.amesim_storage_mass_derivative_from_ports_g_s}, " - f"amesim_storage_dm_residual={inlet_line.amesim_storage_mass_derivative_residual_g_s}, " - f"amesim_chamber_p_abs={inlet_line.amesim_chamber_pressure_pa}, " - f"amesim_chamber_t={inlet_line.amesim_chamber_temperature_k}, " - f"python_p2_abs={inlet_line.python_line_pressure_pa}, " - f"python_t2={inlet_line.python_line_temperature_k}, " - f"python_mgas={inlet_line.python_line_mass_g}, " - f"python_p2_error={inlet_line.python_line_pressure_error_pa}, " - f"python_t2_error={inlet_line.python_line_temperature_error_k}, " - f"python_mgas_error={inlet_line.python_line_mass_error_g}, " - f"python_line_to_node_dm={inlet_line.python_line_to_node_mass_flow_g_s}, " - f"python_line_to_node_dm_error={inlet_line.python_line_to_node_mass_flow_error_g_s}, " - f"python_orifice_to_line_dm={inlet_line.python_orifice_to_line_mass_flow_g_s}, " - f"python_orifice_to_line_dm_error={inlet_line.python_orifice_to_line_mass_flow_error_g_s}, " - f"python_storage_dm={inlet_line.python_storage_mass_derivative_g_s}, " - f"python_storage_dm_error={inlet_line.python_storage_mass_derivative_error_g_s}, " - f"amesim_node_p_abs={inlet_line.amesim_node_pressure_pa}, " - f"amesim_node_t={inlet_line.amesim_node_temperature_k}, " - f"python_line_to_node_amesim_node_dm={inlet_line.python_line_to_node_amesim_node_mass_flow_g_s}, " - f"python_line_to_node_amesim_node_dm_error={inlet_line.python_line_to_node_amesim_node_mass_flow_error_g_s}, " - f"python_line_to_node_amesim_line_dm={inlet_line.python_line_to_node_amesim_line_mass_flow_g_s}, " - f"python_line_to_node_amesim_line_dm_error={inlet_line.python_line_to_node_amesim_line_mass_flow_error_g_s}, " - f"python_line_to_node_amesim_node_line_dm={inlet_line.python_line_to_node_amesim_node_line_mass_flow_g_s}, " - f"python_line_to_node_amesim_node_line_dm_error={inlet_line.python_line_to_node_amesim_node_line_mass_flow_error_g_s}, " - f"python_orifice_to_line_amesim_line_dm={inlet_line.python_orifice_to_line_amesim_line_mass_flow_g_s}, " - f"python_orifice_to_line_amesim_line_dm_error={inlet_line.python_orifice_to_line_amesim_line_mass_flow_error_g_s}, " - f"python_orifice_to_line_amesim_chamber_dm={inlet_line.python_orifice_to_line_amesim_chamber_mass_flow_g_s}, " - f"python_orifice_to_line_amesim_chamber_dm_error={inlet_line.python_orifice_to_line_amesim_chamber_mass_flow_error_g_s}, " - f"python_orifice_to_line_amesim_line_chamber_dm={inlet_line.python_orifice_to_line_amesim_line_chamber_mass_flow_g_s}, " - f"python_orifice_to_line_amesim_line_chamber_dm_error={inlet_line.python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s}, " - f"python_storage_amesim_boundary_states_dm={inlet_line.python_storage_amesim_boundary_states_mass_derivative_g_s}, " - f"python_storage_amesim_boundary_states_dm_error={inlet_line.python_storage_amesim_boundary_states_mass_derivative_error_g_s}, " - f"python_chamber_p_abs={inlet_line.python_chamber_pressure_pa}, " - f"python_chamber_t={inlet_line.python_chamber_temperature_k}, " - f"amesim_fd_dT={inlet_line.amesim_line_temperature_derivative_fd_k_s}, " - f"amesim_dT_p1_h={inlet_line.amesim_port_1_enthalpy_dtemp_component_k_s}, " - f"amesim_dT_p2_h={inlet_line.amesim_port_2_enthalpy_dtemp_component_k_s}, " - f"amesim_dT_m_offset={inlet_line.amesim_mass_offset_dtemp_component_k_s}, " - f"amesim_ref_dT_sum={inlet_line.amesim_reference_temperature_derivative_sum_k_s}, " - f"amesim_ref_dT_residual={inlet_line.amesim_reference_temperature_derivative_residual_k_s}, " - f"python_current_dT={inlet_line.python_current_temperature_derivative_k_s}, " - f"python_dT_p1={inlet_line.python_port_1_current_dtemp_component_k_s}, " - f"python_dT_p2={inlet_line.python_port_2_current_dtemp_component_k_s}, " - f"python_dT_heat={inlet_line.python_heat_dtemp_component_k_s}, " - f"python_dT_error={inlet_line.python_temperature_derivative_error_k_s}, " - f"amesim_fd_dP={inlet_line.amesim_line_pressure_derivative_fd_pa_s}, " - f"amesim_dP_m={inlet_line.amesim_pressure_derivative_mass_component_pa_s}, " - f"amesim_dP_T={inlet_line.amesim_pressure_derivative_temperature_component_pa_s}, " - f"amesim_dP_sum={inlet_line.amesim_pressure_derivative_eos_sum_pa_s}, " - f"amesim_dP_residual={inlet_line.amesim_pressure_derivative_eos_residual_pa_s}, " - f"python_dP_m={inlet_line.python_pressure_derivative_mass_component_pa_s}, " - f"python_dP_T={inlet_line.python_pressure_derivative_temperature_component_pa_s}, " - f"python_dP_sum={inlet_line.python_pressure_derivative_eos_sum_pa_s}, " - f"python_dP_error={inlet_line.python_pressure_derivative_eos_error_pa_s}, " - f"python_ref_dh1={inlet_line.python_reference_port_1_enthalpy_flow_w}, " - f"python_ref_dh2={inlet_line.python_reference_port_2_enthalpy_flow_w}, " - f"python_ref_dT_p1={inlet_line.python_reference_port_1_dtemp_component_k_s}, " - f"python_ref_dT_p2={inlet_line.python_reference_port_2_dtemp_component_k_s}, " - f"python_ref_dT_m_offset={inlet_line.python_reference_mass_offset_dtemp_component_k_s}, " - f"python_ref_dT={inlet_line.python_reference_temperature_derivative_k_s}, " - f"python_ref_dT_error={inlet_line.python_reference_temperature_derivative_error_k_s}, " - f"python_ref_dP_m={inlet_line.python_reference_pressure_derivative_mass_component_pa_s}, " - f"python_ref_dP_T={inlet_line.python_reference_pressure_derivative_temperature_component_pa_s}, " - f"python_ref_dP_sum={inlet_line.python_reference_pressure_derivative_eos_sum_pa_s}, " - f"python_ref_dP_error={inlet_line.python_reference_pressure_derivative_eos_error_pa_s}, " - f"python_ref_amesim_storage_dT_m_offset={inlet_line.python_reference_amesim_storage_mass_offset_dtemp_component_k_s}, " - f"python_ref_amesim_storage_dT={inlet_line.python_reference_amesim_storage_temperature_derivative_k_s}, " - f"python_ref_amesim_storage_dT_error={inlet_line.python_reference_amesim_storage_temperature_derivative_error_k_s}, " - f"python_ref_amesim_storage_dP_m={inlet_line.python_reference_amesim_storage_pressure_derivative_mass_component_pa_s}, " - f"python_ref_amesim_storage_dP_T={inlet_line.python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s}, " - f"python_ref_amesim_storage_dP_sum={inlet_line.python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s}, " - f"python_ref_amesim_storage_dP_error={inlet_line.python_reference_amesim_storage_pressure_derivative_eos_error_pa_s}, " - f"python_active_dT={inlet_line.python_active_temperature_derivative_k_s}, " - f"python_active_dT_error={inlet_line.python_active_temperature_derivative_error_k_s}, " - f"python_active_dP_sum={inlet_line.python_active_pressure_derivative_eos_sum_pa_s}, " - f"python_active_dP_error={inlet_line.python_active_pressure_derivative_eos_error_pa_s}, " - f"python_chamber_p_error={inlet_line.python_chamber_pressure_error_pa}, " - f"python_chamber_t_error={inlet_line.python_chamber_temperature_error_k}" - ) - for pnl0003_energy in sample.pnl0003_energy_diagnostics: - lines.append( - f" - pnl0003_energy@{pnl0003_energy.line_alias}: " - f"amesim_t1={pnl0003_energy.amesim_port_1_temperature_k}, " - f"amesim_t2={pnl0003_energy.amesim_port_2_temperature_k}, " - f"amesim_p1_abs={pnl0003_energy.amesim_port_1_pressure_pa}, " - f"amesim_p2_abs={pnl0003_energy.amesim_port_2_pressure_pa}, " - f"amesim_dm1={pnl0003_energy.amesim_port_1_mass_flow_g_s}, " - f"amesim_dh1={pnl0003_energy.amesim_port_1_enthalpy_flow_w}, " - f"amesim_dmctr={pnl0003_energy.amesim_center_mass_flow_g_s}, " - f"amesim_dhctr={pnl0003_energy.amesim_center_enthalpy_flow_w}, " - f"amesim_dm2={pnl0003_energy.amesim_port_2_mass_flow_g_s}, " - f"amesim_dh2={pnl0003_energy.amesim_port_2_enthalpy_flow_w}, " - f"amesim_sdm1={pnl0003_energy.amesim_port_1_storage_mass_derivative_g_s}, " - f"amesim_sdh1={pnl0003_energy.amesim_port_1_storage_enthalpy_sum_w}, " - f"amesim_sdm2={pnl0003_energy.amesim_port_2_storage_mass_derivative_g_s}, " - f"amesim_sdh2={pnl0003_energy.amesim_port_2_storage_enthalpy_sum_w}, " - f"python_node_dm1={pnl0003_energy.python_pn3_node_port_1_mass_flow_g_s}, " - f"python_node_dm3={pnl0003_energy.python_pn3_node_port_3_mass_flow_g_s}, " - f"python_node_to_line_dm={pnl0003_energy.python_pn3_node_to_line_mass_flow_g_s}, " - f"python_node_to_line_dm_error={pnl0003_energy.python_pn3_node_to_line_mass_flow_error_g_s}, " - f"amesim_m1={pnl0003_energy.amesim_port_1_mass_estimate_g}, " - f"amesim_m2={pnl0003_energy.amesim_port_2_mass_estimate_g}, " - f"python_m1={pnl0003_energy.python_port_1_mass_g}, " - f"python_m2={pnl0003_energy.python_port_2_mass_g}, " - f"python_m1_error={pnl0003_energy.python_port_1_mass_error_to_amesim_g}, " - f"python_m2_error={pnl0003_energy.python_port_2_mass_error_to_amesim_g}, " - f"amesim_fd_dmgas={pnl0003_energy.amesim_total_mass_derivative_fd_g_s}, " - f"amesim_back_dmgas={pnl0003_energy.amesim_total_mass_derivative_backward_g_s}, " - f"amesim_fwd_dmgas={pnl0003_energy.amesim_total_mass_derivative_forward_g_s}, " - f"amesim_storage_dmgas={pnl0003_energy.amesim_total_storage_mass_derivative_g_s}, " - f"amesim_storage_dmgas_residual={pnl0003_energy.amesim_total_storage_mass_derivative_residual_g_s}, " - f"amesim_dmctr_py_sign={pnl0003_energy.amesim_center_flow_python_sign_equivalent_g_s}, " - f"python_center_dm={pnl0003_energy.python_center_mass_flow_g_s}, " - f"python_center_dm_error={pnl0003_energy.amesim_center_flow_python_sign_error_g_s}, " - f"amesim_sdm1_center={pnl0003_energy.amesim_port_1_center_mass_derivative_g_s}, " - f"amesim_sdm2_center={pnl0003_energy.amesim_port_2_center_mass_derivative_g_s}, " - f"python_sdm1_ext={pnl0003_energy.python_port_1_external_mass_flow_g_s}, " - f"python_sdm1_ext_error={pnl0003_energy.python_port_1_external_mass_flow_error_g_s}, " - f"python_sdm1_center={pnl0003_energy.python_port_1_center_mass_derivative_g_s}, " - f"python_sdm1_center_error={pnl0003_energy.python_port_1_center_mass_derivative_error_g_s}, " - f"python_sdm1={pnl0003_energy.python_port_1_storage_mass_derivative_g_s}, " - f"python_sdm1_error={pnl0003_energy.python_port_1_storage_mass_derivative_error_g_s}, " - f"python_sdm2_ext={pnl0003_energy.python_port_2_external_mass_flow_g_s}, " - f"python_sdm2_ext_error={pnl0003_energy.python_port_2_external_mass_flow_error_g_s}, " - f"python_sdm2_center={pnl0003_energy.python_port_2_center_mass_derivative_g_s}, " - f"python_sdm2_center_error={pnl0003_energy.python_port_2_center_mass_derivative_error_g_s}, " - f"python_sdm2={pnl0003_energy.python_port_2_storage_mass_derivative_g_s}, " - f"python_sdm2_error={pnl0003_energy.python_port_2_storage_mass_derivative_error_g_s}, " - f"python_amesim_state_darcy_center_dm=" - f"{pnl0003_energy.python_amesim_state_darcy_center_mass_flow_g_s}, " - f"python_amesim_state_darcy_center_dm_error=" - f"{pnl0003_energy.python_amesim_state_darcy_center_mass_flow_error_g_s}, " - f"python_amesim_state_pn2pipefr_center_dm=" - f"{pnl0003_energy.python_amesim_state_pn2pipefr_center_mass_flow_g_s}, " - f"python_amesim_state_pn2pipefr_center_dm_error=" - f"{pnl0003_energy.python_amesim_state_pn2pipefr_center_mass_flow_error_g_s}, " - f"amesim_center_real_p1=" - f"{pnl0003_energy.amesim_center_real_gas_reference_port_1_estimate_w}, " - f"amesim_center_real_p1_error=" - f"{pnl0003_energy.amesim_center_real_gas_reference_port_1_error_w}, " - f"python_center_ideal_p1=" - f"{pnl0003_energy.python_center_ideal_reference_port_1_estimate_w}, " - f"python_center_ideal_p1_error=" - f"{pnl0003_energy.python_center_ideal_reference_port_1_error_w}, " - f"python_center_ideal_p2=" - f"{pnl0003_energy.python_center_ideal_reference_port_2_estimate_w}, " - f"python_center_ideal_p2_error=" - f"{pnl0003_energy.python_center_ideal_reference_port_2_error_w}, " - f"python_center_real_p1=" - f"{pnl0003_energy.python_center_real_gas_reference_port_1_estimate_w}, " - f"python_center_real_p1_error=" - f"{pnl0003_energy.python_center_real_gas_reference_port_1_error_w}, " - f"python_center_real_p2=" - f"{pnl0003_energy.python_center_real_gas_reference_port_2_estimate_w}, " - f"python_center_real_p2_error=" - f"{pnl0003_energy.python_center_real_gas_reference_port_2_error_w}, " - f"python_dmgas_dt={pnl0003_energy.python_total_mass_derivative_kg_s}, " - f"python_ref_ext1={pnl0003_energy.python_port_1_reference_external_enthalpy_flow_w}, " - f"python_ref_ctr1={pnl0003_energy.python_port_1_reference_center_enthalpy_flow_w}, " - f"python_ref_sdh1={pnl0003_energy.python_port_1_reference_storage_enthalpy_sum_w}, " - f"python_ref_sdh1_error={pnl0003_energy.python_port_1_reference_storage_enthalpy_error_w}, " - f"python_ref_ext2={pnl0003_energy.python_port_2_reference_external_enthalpy_flow_w}, " - f"python_ref_ctr2={pnl0003_energy.python_port_2_reference_center_enthalpy_flow_w}, " - f"python_ref_sdh2={pnl0003_energy.python_port_2_reference_storage_enthalpy_sum_w}, " - f"python_ref_sdh2_error={pnl0003_energy.python_port_2_reference_storage_enthalpy_error_w}, " - f"amesim_dT1_ext_h={pnl0003_energy.amesim_port_1_external_enthalpy_dtemp_component_k_s}, " - f"amesim_dT1_ctr_h={pnl0003_energy.amesim_port_1_center_enthalpy_dtemp_component_k_s}, " - f"amesim_dT1_m_offset={pnl0003_energy.amesim_port_1_mass_offset_dtemp_component_k_s}, " - f"amesim_dT1_heat={pnl0003_energy.amesim_port_1_heat_dtemp_component_k_s}, " - f"python_dT1_ext_h={pnl0003_energy.python_port_1_external_enthalpy_dtemp_component_k_s}, " - f"python_dT1_ctr_h={pnl0003_energy.python_port_1_center_enthalpy_dtemp_component_k_s}, " - f"python_dT1_m_offset={pnl0003_energy.python_port_1_mass_offset_dtemp_component_k_s}, " - f"python_dT1_heat={pnl0003_energy.python_port_1_heat_dtemp_component_k_s}, " - f"amesim_dT2_ext_h={pnl0003_energy.amesim_port_2_external_enthalpy_dtemp_component_k_s}, " - f"amesim_dT2_ctr_h={pnl0003_energy.amesim_port_2_center_enthalpy_dtemp_component_k_s}, " - f"amesim_dT2_m_offset={pnl0003_energy.amesim_port_2_mass_offset_dtemp_component_k_s}, " - f"amesim_dT2_heat={pnl0003_energy.amesim_port_2_heat_dtemp_component_k_s}, " - f"python_dT2_ext_h={pnl0003_energy.python_port_2_external_enthalpy_dtemp_component_k_s}, " - f"python_dT2_ctr_h={pnl0003_energy.python_port_2_center_enthalpy_dtemp_component_k_s}, " - f"python_dT2_m_offset={pnl0003_energy.python_port_2_mass_offset_dtemp_component_k_s}, " - f"python_dT2_heat={pnl0003_energy.python_port_2_heat_dtemp_component_k_s}, " - f"amesim_fd_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_fd_k_s}, " - f"amesim_back_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_backward_k_s}, " - f"amesim_fwd_dT1={pnl0003_energy.amesim_port_1_temperature_derivative_forward_k_s}, " - f"amesim_fd_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_fd_k_s}, " - f"amesim_back_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_backward_k_s}, " - f"amesim_fwd_dT2={pnl0003_energy.amesim_port_2_temperature_derivative_forward_k_s}, " - f"amesim_pn2vol_dT1={pnl0003_energy.amesim_port_1_pn2vol_dtemp_k_s}, " - f"amesim_pn2vol_dT2={pnl0003_energy.amesim_port_2_pn2vol_dtemp_k_s}, " - f"amesim_pn2vol_dT1_residual={pnl0003_energy.amesim_port_1_pn2vol_dtemp_residual_k_s}, " - f"amesim_pn2vol_dT2_residual={pnl0003_energy.amesim_port_2_pn2vol_dtemp_residual_k_s}, " - f"amesim_fd_dP1={pnl0003_energy.amesim_port_1_pressure_derivative_fd_pa_s}, " - f"amesim_fd_dP2={pnl0003_energy.amesim_port_2_pressure_derivative_fd_pa_s}, " - f"amesim_dP1_m={pnl0003_energy.amesim_port_1_pressure_derivative_mass_component_pa_s}, " - f"amesim_dP1_T={pnl0003_energy.amesim_port_1_pressure_derivative_temperature_component_pa_s}, " - f"amesim_dP1_sum={pnl0003_energy.amesim_port_1_pressure_derivative_eos_sum_pa_s}, " - f"amesim_dP1_residual={pnl0003_energy.amesim_port_1_pressure_derivative_eos_residual_pa_s}, " - f"amesim_dP2_m={pnl0003_energy.amesim_port_2_pressure_derivative_mass_component_pa_s}, " - f"amesim_dP2_T={pnl0003_energy.amesim_port_2_pressure_derivative_temperature_component_pa_s}, " - f"amesim_dP2_sum={pnl0003_energy.amesim_port_2_pressure_derivative_eos_sum_pa_s}, " - f"amesim_dP2_residual={pnl0003_energy.amesim_port_2_pressure_derivative_eos_residual_pa_s}, " - f"python_dP1_m={pnl0003_energy.python_port_1_pressure_derivative_mass_component_pa_s}, " - f"python_dP1_T={pnl0003_energy.python_port_1_pressure_derivative_temperature_component_pa_s}, " - f"python_dP1_sum={pnl0003_energy.python_port_1_pressure_derivative_eos_sum_pa_s}, " - f"python_dP2_m={pnl0003_energy.python_port_2_pressure_derivative_mass_component_pa_s}, " - f"python_dP2_T={pnl0003_energy.python_port_2_pressure_derivative_temperature_component_pa_s}, " - f"python_dP2_sum={pnl0003_energy.python_port_2_pressure_derivative_eos_sum_pa_s}, " - f"python_t1={pnl0003_energy.python_port_1_temperature_k}, " - f"python_t2={pnl0003_energy.python_port_2_temperature_k}, " - f"python_p1_abs={pnl0003_energy.python_port_1_pressure_pa}, " - f"python_p2_abs={pnl0003_energy.python_port_2_pressure_pa}, " - f"python_dm1_dt={pnl0003_energy.python_port_1_mass_derivative_kg_s}, " - f"python_dm2_dt={pnl0003_energy.python_port_2_mass_derivative_kg_s}, " - f"python_current_dT1={pnl0003_energy.python_port_1_current_dtemp_k_s}, " - f"python_current_dT2={pnl0003_energy.python_port_2_current_dtemp_k_s}, " - f"python_real_ref_dT1={pnl0003_energy.python_port_1_real_gas_reference_dtemp_k_s}, " - f"python_real_ref_dT2={pnl0003_energy.python_port_2_real_gas_reference_dtemp_k_s}, " - f"python_amesim_center_dT2=" - f"{pnl0003_energy.python_port_2_amesim_center_counterfactual_dtemp_k_s}, " - f"python_amesim_ext_dT2=" - f"{pnl0003_energy.python_port_2_amesim_external_counterfactual_dtemp_k_s}, " - f"python_amesim_ext_ctr_dT2=" - f"{pnl0003_energy.python_port_2_amesim_external_center_counterfactual_dtemp_k_s}, " - f"python_t2_error={pnl0003_energy.python_port_2_temperature_error_to_amesim_k}" - ) - for energy_flow in sample.pnl0001_energy_flow_diagnostics: - lines.append( - f" - energy_flow@{energy_flow.line_alias}: " - f"amesim_dh1={energy_flow.amesim_port_1_enthalpy_flow_w}, " - f"amesim_dm1={energy_flow.amesim_port_1_mass_flow_g_s}, " - f"amesim_node_dh2={energy_flow.amesim_node_port_2_enthalpy_flow_w}, " - f"amesim_node_dm2={energy_flow.amesim_node_port_2_mass_flow_g_s}, " - f"amesim_observed_sdh=" - f"{energy_flow.amesim_observed_port_enthalpy_sum_w}, " - f"python_port1_u=" - f"{energy_flow.python_port_1_internal_energy_flow_w}, " - f"python_port2_u=" - f"{energy_flow.python_port_2_internal_energy_flow_w}, " - f"python_dU_dt={energy_flow.python_current_energy_derivative_w}, " - f"direct_h_dU_dt=" - f"{energy_flow.python_direct_enthalpy_energy_derivative_w}, " - f"direct_minus_current=" - f"{energy_flow.python_direct_minus_current_energy_derivative_w}, " - f"python_p4_node_dh2=" - f"{energy_flow.python_p4_node_port_2_enthalpy_flow_w}, " - f"python_p4_node_dm2=" - f"{energy_flow.python_p4_node_port_2_mass_flow_g_s}, " - f"python_p4_node_h2=" - f"{energy_flow.python_p4_node_port_2_connected_h_j_kg}, " - f"python_p4_node_t2=" - f"{energy_flow.python_p4_node_port_2_connected_temperature_k}, " - f"python_p4_node_h2_delta=" - f"{energy_flow.python_p4_node_port_2_connected_h_delta_to_line_h_j_kg}, " - f"python_p4_node_counterfactual_dT=" - f"{energy_flow.python_p4_node_port_2_counterfactual_dtemp_k_s}, " - f"href_t={energy_flow.reference_temperature_k}, " - f"ref_dh1_est=" - f"{energy_flow.amesim_port_1_reference_enthalpy_estimate_w}, " - f"ref_dh1_error=" - f"{energy_flow.amesim_port_1_reference_enthalpy_error_w}, " - f"candidate_dm2i=" - f"{energy_flow.amesim_candidate_pn2pipefr_dm2i_g_s}, " - f"candidate_dh2i=" - f"{energy_flow.amesim_candidate_pn2pipefr_dh2i_w}, " - f"candidate_storage_sdh=" - f"{energy_flow.amesim_candidate_storage_enthalpy_sum_w}, " - f"candidate_pn2pipefr_dT=" - f"{energy_flow.amesim_candidate_pn2pipefr_dtemp_k_s}, " - f"candidate_pn2pipefr_dT_residual=" - f"{energy_flow.amesim_candidate_pn2pipefr_dtemp_residual_k_s}, " - f"python_ref_dh1=" - f"{energy_flow.python_reference_port_1_enthalpy_flow_w}, " - f"python_ref_node_dh2=" - f"{energy_flow.python_reference_node_port_2_enthalpy_flow_w}, " - f"python_ref_sdh=" - f"{energy_flow.python_reference_observed_port_enthalpy_sum_w}, " - f"python_ref_dh1_error=" - f"{energy_flow.python_reference_port_1_to_amesim_error_w}, " - f"python_ref_node_dh2_error=" - f"{energy_flow.python_reference_node_port_2_to_amesim_error_w}, " - f"python_ref_sdh_error=" - f"{energy_flow.python_reference_sum_to_amesim_error_w}, " - f"python_current_dT=" - f"{energy_flow.python_current_line_temperature_derivative_k_s}, " - f"amesim_fd_dT=" - f"{energy_flow.amesim_line_temperature_derivative_fd_k_s}, " - f"amesim_pn2vol2_dT_node_plus_dh1=" - f"{energy_flow.amesim_pn2vol2_dtemp_node_plus_dh1_k_s}, " - f"amesim_pn2vol2_dT_node_minus_dh1=" - f"{energy_flow.amesim_pn2vol2_dtemp_node_minus_dh1_k_s}, " - f"python_ref_pn2vol2_dT_node_minus_dh1=" - f"{energy_flow.python_reference_pn2vol2_dtemp_node_minus_dh1_k_s}" - ) - for p4_node in sample.p4_node_enthalpy_breakdown_diagnostics: - lines.append( - f" - p4_node_enthalpy@{p4_node.node_alias}: " - f"amesim_p1_dh={p4_node.amesim_port_1_enthalpy_flow_w}, " - f"amesim_p1_dm={p4_node.amesim_port_1_mass_flow_g_s}, " - f"amesim_p3_dh={p4_node.amesim_port_3_enthalpy_flow_w}, " - f"amesim_p3_dm={p4_node.amesim_port_3_mass_flow_g_s}, " - f"amesim_p4_dh={p4_node.amesim_port_4_enthalpy_flow_w}, " - f"amesim_p4_dm={p4_node.amesim_port_4_mass_flow_g_s}, " - f"amesim_p2_dh={p4_node.amesim_port_2_enthalpy_flow_w}, " - f"amesim_p2_dm={p4_node.amesim_port_2_mass_flow_g_s}, " - f"python_p1_dh={p4_node.python_port_1_enthalpy_flow_w}, " - f"python_p1_dm={p4_node.python_port_1_mass_flow_g_s}, " - f"python_p3_dh={p4_node.python_port_3_enthalpy_flow_w}, " - f"python_p3_dm={p4_node.python_port_3_mass_flow_g_s}, " - f"python_p4_dh={p4_node.python_port_4_enthalpy_flow_w}, " - f"python_p4_dm={p4_node.python_port_4_mass_flow_g_s}, " - f"python_p2_dh={p4_node.python_port_2_enthalpy_flow_w}, " - f"python_p2_dm={p4_node.python_port_2_mass_flow_g_s}, " - f"python_ref_p1_dh=" - f"{p4_node.python_reference_port_1_enthalpy_flow_w}, " - f"python_ref_p3_dh=" - f"{p4_node.python_reference_port_3_enthalpy_flow_w}, " - f"python_ref_p4_dh=" - f"{p4_node.python_reference_port_4_enthalpy_flow_w}, " - f"python_ref_p2_dh=" - f"{p4_node.python_reference_port_2_enthalpy_flow_w}, " - f"python_real_ref_p1_dh=" - f"{p4_node.python_real_gas_reference_port_1_enthalpy_flow_w}, " - f"python_real_ref_p3_dh=" - f"{p4_node.python_real_gas_reference_port_3_enthalpy_flow_w}, " - f"python_real_ref_p4_dh=" - f"{p4_node.python_real_gas_reference_port_4_enthalpy_flow_w}, " - f"python_real_ref_p2_dh=" - f"{p4_node.python_real_gas_reference_port_2_enthalpy_flow_w}, " - f"python_p2_dh_error=" - f"{p4_node.python_port_2_enthalpy_error_w}, " - f"python_ref_p2_dh_error=" - f"{p4_node.python_reference_port_2_enthalpy_error_w}, " - f"python_real_ref_p2_dh_error=" - f"{p4_node.python_real_gas_reference_port_2_enthalpy_error_w}" - ) - for chamber_energy in sample.pnch012_energy_equation_diagnostics: - lines.append( - f" - pnch012_energy@{chamber_energy.chamber_alias}: " - f"amesim_dh1={chamber_energy.amesim_port_1_enthalpy_flow_w}, " - f"amesim_dm1={chamber_energy.amesim_port_1_mass_flow_g_s}, " - f"amesim_fd_dmgas=" - f"{chamber_energy.amesim_chamber_mass_derivative_fd_g_s}, " - f"amesim_dmgas_residual=" - f"{chamber_energy.amesim_chamber_mass_derivative_residual_g_s}, " - f"amesim_fd_dT=" - f"{chamber_energy.amesim_chamber_temperature_derivative_fd_k_s}, " - f"amesim_fd_dvol=" - f"{chamber_energy.amesim_chamber_volume_rate_fd_m3_s}, " - f"amesim_piston_dvol=" - f"{chamber_energy.amesim_piston_volume_rate_m3_s}, " - f"amesim_dq={chamber_energy.amesim_heat_flow_w}, " - f"amesim_boundary_work_fd=" - f"{chamber_energy.amesim_boundary_work_fd_volume_w}, " - f"amesim_pn2vol_ref_dT_fdvol=" - f"{chamber_energy.amesim_pn2vol_reference_dtemp_fd_volume_k_s}, " - f"amesim_pn2vol_ref_dT_fdvol_residual=" - f"{chamber_energy.amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s}, " - f"amesim_pn2vol_ref_dT_pistonvol=" - f"{chamber_energy.amesim_pn2vol_reference_dtemp_piston_volume_k_s}, " - f"amesim_pn2vol_ref_dT_pistonvol_residual=" - f"{chamber_energy.amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s}, " - f"python_port_a_dm=" - f"{chamber_energy.python_port_a_mass_flow_kg_s}, " - f"python_dU_dt=" - f"{chamber_energy.python_current_energy_derivative_w}, " - f"python_current_dT=" - f"{chamber_energy.python_current_chamber_temperature_derivative_k_s}, " - f"href_t={chamber_energy.reference_temperature_k}" - ) - lines.append("Final comparison:") - for data_path in diagnostic.data_paths: - lines.append( - f" - {data_path}: " - f"python={diagnostic.python_values_by_data_path[data_path]}, " - f"amesim={diagnostic.amesim_values_by_data_path[data_path]}, " - f"abs_error={diagnostic.abs_error(data_path)}" - ) - return "\n".join(lines) + "\n" - - - -def format_test_mql_pnvo_event_window_candidate_comparison_summary( - diagnostic: TestMqlPnvoEventWindowCandidateComparisonDiagnostic, -) -> str: - lines = [ - "Model: test_mql", - f"Mode: PNVO event window candidate comparison ({diagnostic.orifice_alias})", - f"Event time: {diagnostic.event_time_s}", - f"Final time: {diagnostic.final_time_s}", - "Default RHS: current pneumatic_96 internal-energy RHS", - "Candidate RHS: pneumatic_96 reference-enthalpy/mass-offset RHS", - "Metric comparisons: negative delta means the candidate reduced error", - ] - for comparison in diagnostic.metric_comparisons: - lines.append( - f" t={comparison.time_s} - {comparison.metric_name}: " - f"default_abs_error={comparison.default_abs_error}, " - f"candidate_abs_error={comparison.candidate_abs_error}, " - f"delta={comparison.abs_error_delta}" - ) - lines.append("Final comparison:") - for data_path in diagnostic.default_diagnostic.data_paths: - lines.append( - f" - {data_path}: " - f"default_abs_error={diagnostic.default_diagnostic.abs_error(data_path)}, " - f"candidate_abs_error={diagnostic.candidate_diagnostic.abs_error(data_path)}, " - f"delta=" - f"{diagnostic.candidate_diagnostic.abs_error(data_path) - diagnostic.default_diagnostic.abs_error(data_path)}" - ) - return "\n".join(lines) + "\n" - - -def format_test_mql_full_state_comparison_summary( - run: TestMqlFullStateComparisonRun, -) -> str: - lines = [ - "Model: test_mql", - "Mode: Python 132 full-state closure comparison", - f"Samples: {run.sample_count}", - f"Compared signals: {run.signal_count}", - f"Output schema signals: {run.output_schema.signal_count}", - f"AMESim first saved interval: " - f"{run.amesim_results.times[1] - run.amesim_results.times[0]}", - f"Final time aligns with AMESim sample: " - f"{_matches_amesim_sample(run, run.output.times[-1])}", - f"Max absolute error: {run.comparison.max_abs_error}", - f"Max relative error: {run.comparison.max_rel_error}", - ] - largest_metric = run.largest_abs_error_metric - if largest_metric is not None: - lines.append( - "Largest absolute error: " - f"{largest_metric.data_path}={largest_metric.max_abs_error}" - ) - largest_diagnostic = run.largest_final_abs_error_diagnostic - if largest_diagnostic is not None: - lines.append( - "Largest final endpoint error: " - f"{largest_diagnostic.data_path}={largest_diagnostic.final_abs_error}" - ) - lines.append("Metrics by max absolute error:") - for metric in run.metrics_by_max_abs_error(): - lines.append( - f" - {metric.data_path}: max_abs_error={metric.max_abs_error}, " - f"final_abs_error={metric.final_abs_error}" - ) - lines.append("Endpoint diagnostics by final absolute error:") - for diagnostic in run.diagnostics_by_final_abs_error(): - lines.append( - f" - {diagnostic.data_path}: " - f"initial_python={diagnostic.initial_python_value}, " - f"initial_amesim={diagnostic.initial_amesim_value}, " - f"final_python={diagnostic.final_python_value}, " - f"final_amesim={diagnostic.final_amesim_value}, " - f"final_abs_error={diagnostic.final_abs_error}" - ) - flow_diagnostic = _pneumatic_69_flow_diagnostic(run) - if flow_diagnostic is not None: - lines.append( - "PNL0001 canonical mass-flow diagnostic: " - f"{flow_diagnostic.data_path}" - ) - lines.extend( - [ - " - convention=Python chamber-to-line flow " - "equals -AMESim dm1 * 1e-3", - f" - initial_python_kg_s=" - f"{flow_diagnostic.initial_python_canonical_kg_s}", - f" - initial_amesim_kg_s=" - f"{flow_diagnostic.initial_amesim_canonical_kg_s}", - f" - final_python_kg_s=" - f"{flow_diagnostic.final_python_canonical_kg_s}", - f" - final_amesim_kg_s=" - f"{flow_diagnostic.final_amesim_canonical_kg_s}", - f" - final_abs_error_kg_s=" - f"{flow_diagnostic.final_canonical_abs_error_kg_s}", - ] - ) - pnvo_diagnostic = _pn_morifice_1_diagnostic(run) - if pnvo_diagnostic is not None: - lines.append(f"PNVO diagnostic: {pnvo_diagnostic.alias}") - lines.extend( - [ - f" - initial_python_opening=" - f"{pnvo_diagnostic.initial_python_opening}", - f" - initial_amesim_opening=" - f"{pnvo_diagnostic.initial_amesim_opening}", - f" - final_python_opening=" - f"{pnvo_diagnostic.final_python_opening}", - f" - final_amesim_opening=" - f"{pnvo_diagnostic.final_amesim_opening}", - f" - final_python_mass_flow_kg_s=" - f"{pnvo_diagnostic.final_python_mass_flow_kg_s}", - f" - final_amesim_mass_flow_kg_s=" - f"{pnvo_diagnostic.final_amesim_mass_flow_kg_s}", - f" - final_mass_flow_abs_error_kg_s=" - f"{pnvo_diagnostic.final_mass_flow_abs_error_kg_s}", - ] - ) - chamber_diagnostic = _largest_chamber_rhs_diagnostic(run) - if chamber_diagnostic is not None: - lines.append( - "Largest endpoint chamber RHS breakdown: " - f"{chamber_diagnostic.chamber_alias}" - ) - lines.extend( - [ - f" - piston_alias={chamber_diagnostic.piston_alias}", - f" - pressure_pa={chamber_diagnostic.chamber_pressure_pa}", - f" - volume_m3={chamber_diagnostic.chamber_volume_m3}", - f" - volume_rate_m3_s={chamber_diagnostic.chamber_volume_rate_m3_s}", - f" - mass_derivative_kg_s={chamber_diagnostic.mass_derivative_kg_s}", - f" - port_a_energy_flow_w={chamber_diagnostic.port_a_energy_flow_w}", - f" - boundary_work_w={chamber_diagnostic.boundary_work_w}", - f" - thermal_energy_flow_w=" - f"{chamber_diagnostic.thermal_energy_flow_w}", - f" - energy_derivative_w={chamber_diagnostic.energy_derivative_w}", - ] - ) - return "\n".join(lines) + "\n" - - -def _matches_amesim_sample( - run: TestMqlFullStateComparisonRun, - time_s: float, -) -> bool: - return any( - abs(float(sample_time) - float(time_s)) <= 1.0e-12 - for sample_time in run.amesim_results.times - ) - - -def _pneumatic_69_flow_diagnostic(run: TestMqlFullStateComparisonRun): - try: - return run.pnl0001_mass_flow_diagnostic() - except KeyError: - return None - - -def _pn_morifice_1_diagnostic(run: TestMqlFullStateComparisonRun): - try: - return run.pnvo_diagnostic() - except KeyError: - return None - - -def _largest_chamber_rhs_diagnostic(run: TestMqlFullStateComparisonRun): - diagnostic = run.largest_final_abs_error_diagnostic - if diagnostic is None or "@" not in diagnostic.data_path: - return None - _signal, alias = diagnostic.data_path.split("@", 1) - try: - return run.chamber_rhs_diagnostic(alias) - except KeyError: - return None - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument( - "--pnvo-event-boundary", - action="store_true", - help="compare the STEP0 left-state/right-opening boundary at t=0.04 s", - ) - parser.add_argument( - "--pnvo-event-window", - action="store_true", - help="run the segmented PNVO opening window through the t=0.05 s save point", - ) - parser.add_argument( - "--pnvo-event-window-candidate-comparison", - action="store_true", - help="compare the PNVO event window with and without the pneumatic_96 RHS candidate", - ) - parser.add_argument( - "--pnvo-event-window-final-time", - type=float, - help="override PNVO event window final time; candidate comparison defaults to 0.041 s", - ) - parser.add_argument( - "--amesim-results-archive", - type=Path, - help="read AMESim observations from a separate .ame results package", - ) - parser.add_argument( - "--pneumatic-96-reference-rhs", - action="store_true", - help="enable the local reference/mass-offset RHS candidate for pneumatic_96", - ) - parser.add_argument( - "--pneumatic-69-reference-rhs", - action="store_true", - help="enable the local reference/mass-offset RHS candidate for pneumatic_69", - ) - args = parser.parse_args() - config = None - if ( - args.amesim_results_archive is not None - or args.pneumatic_96_reference_rhs - or args.pneumatic_69_reference_rhs - ): - config = TestMqlFullStateComparisonScriptConfig( - paths=TestMqlFullStateComparisonPathConfig( - amesim_results_archive_path=args.amesim_results_archive, - ), - execution=TestMqlFullStateComparisonExecutionConfig( - use_pneumatic_96_reference_rhs=args.pneumatic_96_reference_rhs, - use_pneumatic_69_reference_rhs=args.pneumatic_69_reference_rhs, - ), - ) - if args.pnvo_event_boundary: - diagnostic = run_test_mql_pnvo_event_boundary_diagnostic(config) - print(format_test_mql_pnvo_event_boundary_summary(diagnostic), end="") - return - if args.pnvo_event_window_candidate_comparison: - diagnostic = run_test_mql_pnvo_event_window_candidate_comparison( - config, - final_time_s=args.pnvo_event_window_final_time or 0.041, - ) - print( - format_test_mql_pnvo_event_window_candidate_comparison_summary( - diagnostic - ), - end="", - ) - return - if args.pnvo_event_window: - if args.pnvo_event_window_final_time is None: - diagnostic = run_test_mql_pnvo_event_window_diagnostic(config) - else: - diagnostic = run_test_mql_pnvo_event_window_diagnostic( - config, - final_time_s=args.pnvo_event_window_final_time, - ) - print(format_test_mql_pnvo_event_window_summary(diagnostic), end="") - return - - run, output_dir = run_test_mql_full_state_comparison(config) - print(format_test_mql_full_state_comparison_summary(run), end="") - print(f"Output directory: {output_dir}") - - -if __name__ == "__main__": - main() diff --git a/app/simulation/examples/test_mql/solver.py b/app/simulation/examples/test_mql/solver.py deleted file mode 100644 index e9ca298..0000000 --- a/app/simulation/examples/test_mql/solver.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.solvers.solver import ( - SolveIVPConfig as _BaseSolveIVPConfig, - integrate_ode, -) - - -@dataclass(frozen=True) -class SolveIVPConfig(_BaseSolveIVPConfig): - """Solver defaults used by the calibrated ``test_mql`` example.""" - - atol: float = 1e-10 - - -__all__ = ["SolveIVPConfig", "integrate_ode"] diff --git a/app/simulation/examples/test_mql/structural_network.py b/app/simulation/examples/test_mql/structural_network.py deleted file mode 100644 index 5f96f43..0000000 --- a/app/simulation/examples/test_mql/structural_network.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.core.base import Component, DynamicComponent - - -@dataclass(frozen=True) -class Connection: - source_component: str - source_port: str - target_component: str - target_port: str - - -class SimulationNetwork: - """Container for components, topology, and state-vector bookkeeping.""" - - def __init__(self, name: str) -> None: - self.name = name - self.components: dict[str, Component] = {} - self.connections: list[Connection] = [] - - def add_component(self, component: Component) -> None: - if component.name in self.components: - raise ValueError(f"Duplicate component name: {component.name}") - self.components[component.name] = component - - def connect( - self, - source_component: str, - source_port: str, - target_component: str, - target_port: str, - ) -> None: - self.connections.append( - Connection( - source_component=source_component, - source_port=source_port, - target_component=target_component, - target_port=target_port, - ) - ) - - def dynamic_components(self) -> list[DynamicComponent]: - return [ - component - for component in self.components.values() - if isinstance(component, DynamicComponent) - ] - - def initial_state_vector(self) -> list[float]: - values: list[float] = [] - for component in self.dynamic_components(): - values.extend(component.get_state_vector()) - return values - - def apply_state_vector(self, values: list[float]) -> None: - cursor = 0 - for component in self.dynamic_components(): - next_cursor = cursor + component.state_size - component.set_state_vector(values[cursor:next_cursor]) - cursor = next_cursor - if cursor != len(values): - raise ValueError("State vector length does not match dynamic components.") - - def summary(self) -> str: - lines = [f"Network: {self.name}", "Components:"] - for name, component in self.components.items(): - lines.append(f" - {name}: {component.__class__.__name__}") - lines.append("Connections:") - for conn in self.connections: - lines.append( - f" - {conn.source_component}.{conn.source_port}" - f" -> {conn.target_component}.{conn.target_port}" - ) - return "\n".join(lines) - diff --git a/app/simulation/examples/test_mql/system.py b/app/simulation/examples/test_mql/system.py deleted file mode 100644 index 1e5635f..0000000 --- a/app/simulation/examples/test_mql/system.py +++ /dev/null @@ -1,6334 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from types import SimpleNamespace - -from app.simulation.core.base import Component -from app.simulation.examples.test_mql.solver import SolveIVPConfig, integrate_ode -from app.simulation.examples.test_mql.structural_network import SimulationNetwork -from app.simulation.paths import AMESIM_TEST_MQL_ARCHIVE_PATH - - -MODEL_NAME = "test_mql" - -# Extracted from AmesimModels/test_mql.ame on the model-development branch. -# This is a structural Python port scaffold: it preserves the AMESim component -# inventory, parameters, and connection topology without changing the existing -# Testmodel implementation. The detailed AMESim submodel equations should be -# replaced incrementally per submodel family. -MODEL_INFO = {'num_states': 132, 'num_discrete_states': 24, 'is_explicit': 1} -SIMULATION_SETTINGS = {'raw': '0 10 0.01 1e+30 1e-07 0.001 1 0.1\r\n0 0 0 0 8 0 0 0 0 0', - 't_start': 0.0, - 't_stop': 10.0, - 'print_interval': 0.01, - 'max_step': 0.001, - 'sample_step': 0.1} -GLOBAL_PARAMETERS = {'D2': '20', 'D1': '20', 'D3': '14', 'Pdq': '1', 'P0': '153', 'V': '15', 'cf': '0.45'} -SUBMODEL_COUNTS = {'PNRP17': 8, - 'F000': 16, - 'MECMAS21': 10, - 'LMECHN1': 2, - 'LSTP00A': 8, - 'PNCH012': 8, - 'PNPL01': 16, - 'FORC': 2, - 'PNCH023': 4, - 'PNOR001': 8, - 'PN3NODE2': 8, - 'P4NODE2': 8, - 'PNVO001': 8, - 'STEP0': 8, - 'UD00': 2, - 'PNGD00': 1} -LINE_SUBMODEL_COUNTS = {'DIRECT': 44, 'PNL0001': 20, 'PNL0003': 8, 'PNL0002': 8, 'PNL00R': 4} -COMPONENT_SPECS = [{'index': 0, - 'alias': 'pn_brp2_8', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 1, - 'alias': 'pn_brp2_9', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 2, - 'alias': 'pn_brp2_10', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 3, - 'alias': 'pn_brp2_11', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 4, - 'alias': 'pn_brp2_12', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 5, - 'alias': 'pn_brp2_13', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 6, - 'alias': 'pn_brp2_14', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 7, - 'alias': 'pn_brp2_15', - 'component_name': 'pn_brp2', - 'library_id': 'pcd.pn_brp2', - 'submodel': 'PNRP17', - 'label': 'pneumatic piston with moving body', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'dp', - 'title': 'piston diameter', - 'value': '2.00000000000000e+02', - 'units': 'mm'}, - {'name': 'dr', - 'title': 'rod diameter', - 'value': '1.00000000000000e+00', - 'units': 'mm'}, - {'name': 'x0', - 'title': 'chamber length at zero relative displacement', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 8, - 'alias': 'zeroforcesource_17', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 9, - 'alias': 'mass_friction_endstops_10', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 10, - 'alias': 'zeroforcesource_18', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 11, - 'alias': 'mass_friction_endstops_11', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 12, - 'alias': 'zeroforcesource_19', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 13, - 'alias': 'mass_friction_endstops_12', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 14, - 'alias': 'zeroforcesource_20', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 15, - 'alias': 'mass_friction_endstops_13', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 16, - 'alias': 'zeroforcesource_21', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 17, - 'alias': 'mass_friction_endstops_14', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 18, - 'alias': 'zeroforcesource_22', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 19, - 'alias': 'mass_friction_endstops_15', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 20, - 'alias': 'zeroforcesource_23', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 21, - 'alias': 'mass_friction_endstops_16', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 22, - 'alias': 'zeroforcesource_24', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 23, - 'alias': 'mass_friction_endstops_17', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '5.00000000000000e+01', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-1.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '8.00000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '4', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 24, - 'alias': 'dynamic_mechanical_node_alternative_2', - 'component_name': 'dynamic_mechanical_node_alternative', - 'library_id': 'meca.dynamic_mechanical_node_alternative', - 'submodel': 'LMECHN1', - 'label': 'dynamic linear mechanical node (velocity and displacement)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_6', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_7', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_8', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_9', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'v1', 'title': 'number of ports at right', 'value': '8', 'units': ''}, - {'name': 'sum', 'title': 'node sum', 'value': '1', 'units': ''}]}, - {'index': 25, - 'alias': 'mass_friction_endstops_18', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '1.70000000000000e+05', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '0.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '3.70000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 26, - 'alias': 'elasticendstop_8', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 27, - 'alias': 'elasticendstop_9', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 28, - 'alias': 'elasticendstop_10', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 29, - 'alias': 'elasticendstop_11', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 30, - 'alias': 'elasticendstop_12', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 31, - 'alias': 'elasticendstop_13', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 32, - 'alias': 'elasticendstop_14', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 33, - 'alias': 'elasticendstop_15', - 'component_name': 'elasticendstop', - 'library_id': 'meca.elasticendstop', - 'submodel': 'LSTP00A', - 'label': 'elastic contact (no states)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': [{'name': 'na', - 'title': 'number of active coils', - 'value': '1.00000000000000e+01', - 'units': 'null'}, - {'name': 'gap0', - 'title': 'gap or clearance with both displacements zero', - 'value': '0.00000000000000e+00', - 'units': 'mm'}, - {'name': 'kcont', - 'title': 'contact stiffness', - 'value': '1.00000000000000e+11', - 'units': 'N/m'}, - {'name': 'G', - 'title': 'material shear modulus', - 'value': '8.57000000000000e+10', - 'units': 'N/m**2'}, - {'name': 'sdiam', - 'title': 'spring diameter', - 'value': '2.00000000000000e+01', - 'units': 'mm'}, - {'name': 'wdiam', - 'title': 'wire diameter', - 'value': '2.00000000000000e+00', - 'units': 'mm'}, - {'name': 'rcont', - 'title': 'contact damping', - 'value': '1.00000000000000e+11', - 'units': 'N/(m/s)'}, - {'name': 'Pdis', - 'title': 'penetration for full damping', - 'value': '1.00000000000000e-04', - 'units': 'mm'}, - {'name': 'stiffmode', 'title': 'spring stiffness mode', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}]}, - {'index': 34, - 'alias': 'dynamic_mechanical_node_alternative_3', - 'component_name': 'dynamic_mechanical_node_alternative', - 'library_id': 'meca.dynamic_mechanical_node_alternative', - 'submodel': 'LMECHN1', - 'label': 'dynamic linear mechanical node (velocity and displacement)', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_3', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_4', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_5', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_6', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_7', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_8', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_9', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'v1', 'title': 'number of ports at right', 'value': '8', 'units': ''}, - {'name': 'sum', 'title': 'node sum', 'value': '1', 'units': ''}]}, - {'index': 35, - 'alias': 'mass_friction_endstops_19', - 'component_name': 'mass_friction_endstops', - 'library_id': 'meca.mass_friction_endstops', - 'submodel': 'MECMAS21', - 'label': '1D translation mass', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': [{'name': 'mass', 'title': 'mass', 'value': '9.00000000000000e+04', 'units': 'kg'}, - {'name': 'fstick', - 'title': 'stiction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'fcoul', - 'title': 'Coulomb friction force', - 'value': '0.00000000000000e+00', - 'units': 'N'}, - {'name': 'rvisc', - 'title': 'coefficient of viscous friction', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)'}, - {'name': 'wind', - 'title': 'coefficient of windage', - 'value': '0.00000000000000e+00', - 'units': 'N/(m/s)**2'}, - {'name': 'dvel', - 'title': 'stick velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restdvel', - 'title': 'velocity threshold', - 'value': '1.00000000000000e-06', - 'units': 'm/s'}, - {'name': 'restcoeff', - 'title': 'restitution coefficient', - 'value': '6.50000000000000e-01', - 'units': 'null'}, - {'name': 'astrib', - 'title': 'Stribeck constant', - 'value': '1.00000000000000e-03', - 'units': 'm/s'}, - {'name': 'xmin', - 'title': 'lower displacement limit', - 'value': '-7.20000000000000e-01', - 'units': 'm'}, - {'name': 'Kbmin', - 'title': 'lower limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmin', - 'title': 'lower limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmin', - 'title': 'lower limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'xmax', - 'title': 'higher displacement limit', - 'value': '0.00000000000000e+00', - 'units': 'm'}, - {'name': 'Kbmax', - 'title': 'higher limit stiffness', - 'value': '1.00000000000000e+06', - 'units': 'N/mm'}, - {'name': 'Dbmax', - 'title': 'higher limit contact damping coefficient', - 'value': '1.00000000000000e+01', - 'units': 'N/(mm/s)'}, - {'name': 'Pdmax', - 'title': 'higher limit penetration for full damping', - 'value': '1.00000000000000e-01', - 'units': 'mm'}, - {'name': 'theta', - 'title': 'inclination (+90 port 1 lowest, -90 port 1 highest)', - 'value': '0.00000000000000e+00', - 'units': 'degree'}, - {'name': 'useFriction', 'title': 'use friction', 'value': '1', 'units': ''}, - {'name': 'stoptype', 'title': 'endstop type', 'value': '1', 'units': ''}, - {'name': 'discContactOption', - 'title': 'negative contact force', - 'value': '1', - 'units': ''}, - {'name': 'strib', 'title': 'Stribeck effect', 'value': '1', 'units': ''}, - {'name': 'frictionType', 'title': 'friction type', 'value': '1', 'units': ''}]}, - {'index': 36, - 'alias': 'pn_c1_8', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 37, - 'alias': 'pn_plug_16', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 38, - 'alias': 'pn_plug_17', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 39, - 'alias': 'pn_c1_9', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 40, - 'alias': 'pn_plug_18', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 41, - 'alias': 'pn_plug_19', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 42, - 'alias': 'pn_c1_10', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 43, - 'alias': 'pn_plug_20', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 44, - 'alias': 'pn_plug_21', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 45, - 'alias': 'pn_c1_11', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 46, - 'alias': 'pn_plug_22', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 47, - 'alias': 'pn_plug_23', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 48, - 'alias': 'pn_c1_12', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 49, - 'alias': 'pn_plug_24', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 50, - 'alias': 'pn_plug_25', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 51, - 'alias': 'pn_c1_13', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 52, - 'alias': 'pn_plug_26', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 53, - 'alias': 'pn_plug_27', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 54, - 'alias': 'pn_c1_14', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 55, - 'alias': 'pn_plug_28', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 56, - 'alias': 'pn_plug_29', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 57, - 'alias': 'pn_c1_15', - 'component_name': 'pn_c1', - 'library_id': 'pcd.pn_c1', - 'submodel': 'PNCH012', - 'label': 'variable volume pneumatic chamber with heat exchange (preferred)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '2'}, - {'name': 'port_2', 'type': 'pflow', 'face': '1'}, - {'name': 'port_3', 'type': 'pflow', 'face': '3'}, - {'name': 'port_4', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol0', 'title': 'dead volume', 'value': 'V', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '1.50000000000000e+03', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '7.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 58, - 'alias': 'pn_plug_30', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}], - 'parameters': []}, - {'index': 59, - 'alias': 'pn_plug_31', - 'component_name': 'pn_plug', - 'library_id': 'pn.pn_plug', - 'submodel': 'PNPL01', - 'label': 'zero pneumatic flow source', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 60, - 'alias': 'zeroforcesource_26', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 61, - 'alias': 'zeroforcesource_27', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 62, - 'alias': 'zeroforcesource_28', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 63, - 'alias': 'zeroforcesource_29', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 64, - 'alias': 'zeroforcesource_30', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 65, - 'alias': 'zeroforcesource_31', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 66, - 'alias': 'zeroforcesource_32', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 67, - 'alias': 'zeroforcesource_33', - 'component_name': 'zeroforcesource', - 'library_id': 'meca.zeroforcesource', - 'submodel': 'F000', - 'label': 'zero force source', - 'ports': [{'name': 'port_1', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 68, - 'alias': 'forcecon_1', - 'component_name': 'forcecon', - 'library_id': 'meca.forcecon', - 'submodel': 'FORC', - 'label': 'conversion of signal input into a force in N', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '2'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '3'}], - 'parameters': []}, - {'index': 69, - 'alias': 'pn_general_chamber', - 'component_name': 'pn_general_chamber', - 'library_id': 'pn.pn_general_chamber', - 'submodel': 'PNCH023', - 'label': 'simple pneumatic chamber with heat exchange', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '0.00000000000000e+00', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '1.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 70, - 'alias': 'pn_orifice_18', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 71, - 'alias': 'pn_orifice_19', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 72, - 'alias': 'pn_node3_8', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 73, - 'alias': 'pn_node3_9', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 74, - 'alias': 'pn_general_chamber_2', - 'component_name': 'pn_general_chamber', - 'library_id': 'pn.pn_general_chamber', - 'submodel': 'PNCH023', - 'label': 'simple pneumatic chamber with heat exchange', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '0.00000000000000e+00', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '1.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 75, - 'alias': 'pn_orifice_20', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 76, - 'alias': 'pn_orifice_21', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 77, - 'alias': 'pn_node3_10', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 78, - 'alias': 'pn_node3_11', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 79, - 'alias': 'pn_general_chamber_4', - 'component_name': 'pn_general_chamber', - 'library_id': 'pn.pn_general_chamber', - 'submodel': 'PNCH023', - 'label': 'simple pneumatic chamber with heat exchange', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '0.00000000000000e+00', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '1.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 80, - 'alias': 'pn_orifice_22', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 81, - 'alias': 'pn_orifice_23', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 82, - 'alias': 'pn_node3_12', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 83, - 'alias': 'pn_node3_13', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 84, - 'alias': 'pn_general_chamber_5', - 'component_name': 'pn_general_chamber', - 'library_id': 'pn.pn_general_chamber', - 'submodel': 'PNCH023', - 'label': 'simple pneumatic chamber with heat exchange', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cvol', 'title': 'volume', 'value': '5.70000000000000e+01', 'units': 'L'}, - {'name': 'kth', - 'title': 'thermal exchange coefficient', - 'value': '0.00000000000000e+00', - 'units': 'J/m**2/K/s'}, - {'name': 'sth', - 'title': 'thermal exchange area', - 'value': '1.00000000000000e-01', - 'units': 'm**2'}, - {'name': 'extemp', - 'title': 'external temperature', - 'value': '2.93150000000000e+02', - 'units': 'K'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}]}, - {'index': 85, - 'alias': 'pn_orifice_24', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 86, - 'alias': 'pn_orifice_25', - 'component_name': 'pn_orifice', - 'library_id': 'pn.pn_orifice', - 'submodel': 'PNOR001', - 'label': 'pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '0'}], - 'parameters': [{'name': 'cq', - 'title': 'flow coefficient (Cq)', - 'value': '9.00000000000000e-01', - 'units': 'null'}, - {'name': 'area', - 'title': 'orifice area', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 87, - 'alias': 'pn_node3_14', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 88, - 'alias': 'pn_node3_15', - 'component_name': 'pn_node3', - 'library_id': 'pn.pn_node3', - 'submodel': 'PN3NODE2', - 'label': 'pneumatic junction 3 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}], - 'parameters': []}, - {'index': 89, - 'alias': 'pnnode4_16', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 90, - 'alias': 'pnnode4_17', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 91, - 'alias': 'pnnode4_18', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 92, - 'alias': 'pnnode4_19', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 93, - 'alias': 'pnnode4_20', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 94, - 'alias': 'pnnode4_21', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 95, - 'alias': 'pnnode4_22', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 96, - 'alias': 'pnnode4_23', - 'component_name': 'pnnode4', - 'library_id': 'pn.pnnode4', - 'submodel': 'P4NODE2', - 'label': 'pneumatic junction 4 ports (temperature and pressure fixed by port 2)', - 'ports': [{'name': 'port_1', 'type': 'pflow', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '0'}, - {'name': 'port_4', 'type': 'pflow', 'face': '2'}], - 'parameters': []}, - {'index': 97, - 'alias': 'pn_morifice_11', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 98, - 'alias': 'step_8', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 99, - 'alias': 'pn_morifice_12', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 100, - 'alias': 'step_9', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 101, - 'alias': 'pn_morifice_13', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 102, - 'alias': 'step_10', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 103, - 'alias': 'pn_morifice_14', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 104, - 'alias': 'step_11', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 105, - 'alias': 'forcecon_2', - 'component_name': 'forcecon', - 'library_id': 'meca.forcecon', - 'submodel': 'FORC', - 'label': 'conversion of signal input into a force in N', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}, - {'name': 'port_2', 'type': 'lshaft', 'face': '2'}], - 'parameters': []}, - {'index': 106, - 'alias': 'piecewiselinear', - 'component_name': 'piecewiselinear', - 'library_id': 'sig.piecewiselinear', - 'submodel': 'UD00', - 'label': 'piecewise linear signal source', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'tstart', - 'title': 'time at which duty cycle starts', - 'value': '0.00000000000000e+00', - 'units': 's'}, - {'name': 'start1', - 'title': 'output at start of stage 1', - 'value': '1.00000000000000e+17', - 'units': 'null'}, - {'name': 'end1', - 'title': 'output at end of stage 1', - 'value': '1.00000000000000e+17', - 'units': 'null'}, - {'name': 't1', - 'title': 'duration of stage 1', - 'value': '8.00000000000000e-01', - 'units': 's'}, - {'name': 'start2', - 'title': 'output at start of stage 2', - 'value': '4.90000000000000e+04', - 'units': 'null'}, - {'name': 'end2', - 'title': 'output at end of stage 2', - 'value': '4.90000000000000e+04', - 'units': 'null'}, - {'name': 't2', - 'title': 'duration of stage 2', - 'value': '1.00000000000000e+01', - 'units': 's'}, - {'name': 'start3', - 'title': 'output at start of stage 3', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end3', - 'title': 'output at end of stage 3', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't3', - 'title': 'duration of stage 3', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start4', - 'title': 'output at start of stage 4', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end4', - 'title': 'output at end of stage 4', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't4', - 'title': 'duration of stage 4', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start5', - 'title': 'output at start of stage 5', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end5', - 'title': 'output at end of stage 5', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't5', - 'title': 'duration of stage 5', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start6', - 'title': 'output at start of stage 6', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end6', - 'title': 'output at end of stage 6', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't6', - 'title': 'duration of stage 6', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start7', - 'title': 'output at start of stage 7', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end7', - 'title': 'output at end of stage 7', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't7', - 'title': 'duration of stage 7', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start8', - 'title': 'output at start of stage 8', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end8', - 'title': 'output at end of stage 8', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't8', - 'title': 'duration of stage 8', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'nstages', 'title': 'number of stages', 'value': '2', 'units': ''}, - {'name': 'iscyclic', 'title': 'cyclic', 'value': '1', 'units': ''}]}, - {'index': 107, - 'alias': 'piecewiselinear_1', - 'component_name': 'piecewiselinear', - 'library_id': 'sig.piecewiselinear', - 'submodel': 'UD00', - 'label': 'piecewise linear signal source', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '2'}], - 'parameters': [{'name': 'tstart', - 'title': 'time at which duty cycle starts', - 'value': '0.00000000000000e+00', - 'units': 's'}, - {'name': 'start1', - 'title': 'output at start of stage 1', - 'value': '1.00000000000000e+12', - 'units': 'null'}, - {'name': 'end1', - 'title': 'output at end of stage 1', - 'value': '1.00000000000000e+12', - 'units': 'null'}, - {'name': 't1', - 'title': 'duration of stage 1', - 'value': '8.00000000000000e-01', - 'units': 's'}, - {'name': 'start2', - 'title': 'output at start of stage 2', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end2', - 'title': 'output at end of stage 2', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't2', - 'title': 'duration of stage 2', - 'value': '1.00000000000000e+01', - 'units': 's'}, - {'name': 'start3', - 'title': 'output at start of stage 3', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end3', - 'title': 'output at end of stage 3', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't3', - 'title': 'duration of stage 3', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start4', - 'title': 'output at start of stage 4', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end4', - 'title': 'output at end of stage 4', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't4', - 'title': 'duration of stage 4', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start5', - 'title': 'output at start of stage 5', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end5', - 'title': 'output at end of stage 5', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't5', - 'title': 'duration of stage 5', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start6', - 'title': 'output at start of stage 6', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end6', - 'title': 'output at end of stage 6', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't6', - 'title': 'duration of stage 6', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start7', - 'title': 'output at start of stage 7', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end7', - 'title': 'output at end of stage 7', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't7', - 'title': 'duration of stage 7', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'start8', - 'title': 'output at start of stage 8', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'end8', - 'title': 'output at end of stage 8', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 't8', - 'title': 'duration of stage 8', - 'value': '3.00000000000000e+00', - 'units': 's'}, - {'name': 'nstages', 'title': 'number of stages', 'value': '2', 'units': ''}, - {'name': 'iscyclic', 'title': 'cyclic', 'value': '1', 'units': ''}]}, - {'index': 108, - 'alias': 'pn_morifice_1', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 109, - 'alias': 'step_12', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 110, - 'alias': 'pn_morifice_9', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 111, - 'alias': 'step_13', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 112, - 'alias': 'pn_morifice_10', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 113, - 'alias': 'step_14', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 114, - 'alias': 'pn_morifice_15', - 'component_name': 'pn_morifice', - 'library_id': 'pn.pn_morifice', - 'submodel': 'PNVO001', - 'label': 'modulated pneumatic orifice (constant flow coefficient)', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '1'}, - {'name': 'port_2', 'type': 'pflow', 'face': '3'}, - {'name': 'port_3', 'type': 'pflow', 'face': '2'}], - 'parameters': [{'name': 'cq', 'title': 'flow coefficient (Cq)', 'value': 'cf', 'units': 'null'}, - {'name': 'area0', - 'title': 'orifice area at maximum opening', - 'value': '3.14*10^2/4', - 'units': 'mm**2'}, - {'name': 'Cv', - 'title': 'maximum flow coefficient (Cv)', - 'value': '5.00000000000000e-01', - 'units': 'null'}, - {'name': 'Kv', - 'title': 'maximum flow coefficient (Kv)', - 'value': '4.00000000000000e-01', - 'units': 'null'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'flowset', - 'title': 'flow coefficient setting', - 'value': '1', - 'units': ''}]}, - {'index': 115, - 'alias': 'step_15', - 'component_name': 'step', - 'library_id': 'sig.step', - 'submodel': 'STEP0', - 'label': 'step function', - 'ports': [{'name': 'port_1', 'type': 'signal', 'face': '3'}], - 'parameters': [{'name': 'out0', - 'title': 'value before step', - 'value': '0.00000000000000e+00', - 'units': 'null'}, - {'name': 'out1', - 'title': 'value after step', - 'value': '1.00000000000000e+00', - 'units': 'null'}, - {'name': 't0', - 'title': 'step time', - 'value': '4.00000000000000e-02', - 'units': 's'}, - {'name': 'td', - 'title': 'transition duration', - 'value': '1.00000000000000e-01', - 'units': 's'}, - {'name': 'transitionType', - 'title': 'transition type', - 'value': '1', - 'units': ''}]}, - {'index': 116, - 'alias': 'pn_gas_data', - 'component_name': 'pn_gas_data', - 'library_id': 'pn.pn_gas_data', - 'submodel': 'PNGD00', - 'label': 'gas definition for helium (He)', - 'ports': [], - 'parameters': [{'name': 'cp', - 'title': 'constant-pressure specific heat (Cp)', - 'value': '1.00520000000000e+03', - 'units': 'J/kg/K'}, - {'name': 'cv', - 'title': 'constant-volume specific heat (Cv)', - 'value': '7.18000000000000e+02', - 'units': 'J/kg/K'}, - {'name': 'gamma', - 'title': 'specific heat ratio (gamma)', - 'value': '1.40000000000000e+00', - 'units': 'null'}, - {'name': 'rGas', - 'title': 'specific gas constant (r)', - 'value': '2.87200000000000e+02', - 'units': 'J/kg/K'}, - {'name': 'mu', - 'title': 'absolute viscosity (mu)', - 'value': '1.82000000000000e-02', - 'units': 'cP'}, - {'name': 'lam', - 'title': 'thermal conductivity', - 'value': '2.64000000000000e-02', - 'units': 'W/m/K'}, - {'name': 'M', - 'title': 'molar mass', - 'value': '2.89651300000000e+01', - 'units': 'g/mol'}, - {'name': 'operatingTempK', - 'title': 'operating temperature', - 'value': '2.98150000000000e+02', - 'units': 'K'}, - {'name': 'enthForm', - 'title': 'enthalpy of formation', - 'value': '-1.25530000000000e+02', - 'units': 'J/mol'}, - {'name': 'gi', 'title': 'gas type index', 'value': '1', 'units': ''}, - {'name': 'fluidType', 'title': 'fluid definition', 'value': '12', 'units': ''}, - {'name': 'eosType', 'title': 'properties definition', 'value': '6', 'units': ''}, - {'name': 'gasSetting', - 'title': 'setting properties through', - 'value': '1', - 'units': ''}]}] -CONNECTION_SPECS = [{'index': 0, - 'alias': 'linear_24', - 'source_component': 'pn_brp2_8', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_8', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 1, - 'alias': 'linear_25', - 'source_component': 'elasticendstop_8', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 2, - 'alias': 'linear_26', - 'source_component': 'pn_brp2_9', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_9', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 3, - 'alias': 'linear_27', - 'source_component': 'elasticendstop_9', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_2', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 4, - 'alias': 'linear_28', - 'source_component': 'pn_brp2_10', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_10', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 5, - 'alias': 'linear_29', - 'source_component': 'pn_brp2_11', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_11', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 6, - 'alias': 'linear_30', - 'source_component': 'pn_brp2_12', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_12', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 7, - 'alias': 'linear_31', - 'source_component': 'pn_brp2_13', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_13', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 8, - 'alias': 'linear_32', - 'source_component': 'pn_brp2_14', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_14', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 9, - 'alias': 'linear_33', - 'source_component': 'pn_brp2_15', - 'source_port': 'port_5', - 'target_component': 'elasticendstop_15', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 10, - 'alias': 'linear_34', - 'source_component': 'elasticendstop_10', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 11, - 'alias': 'linear_35', - 'source_component': 'elasticendstop_11', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_4', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 12, - 'alias': 'linear_36', - 'source_component': 'elasticendstop_12', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_5', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 13, - 'alias': 'linear_37', - 'source_component': 'elasticendstop_13', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_6', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 14, - 'alias': 'linear_38', - 'source_component': 'elasticendstop_14', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_7', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 15, - 'alias': 'linear_39', - 'source_component': 'elasticendstop_15', - 'source_port': 'port_2', - 'target_component': 'dynamic_mechanical_node_alternative_2', - 'target_port': 'port_8', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 16, - 'alias': 'linear_40', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_8', - 'target_component': 'pn_brp2_8', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 17, - 'alias': 'linear_41', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_7', - 'target_component': 'pn_brp2_9', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 18, - 'alias': 'linear_42', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_6', - 'target_component': 'pn_brp2_10', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 19, - 'alias': 'linear_43', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_5', - 'target_component': 'pn_brp2_11', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 20, - 'alias': 'linear_44', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_4', - 'target_component': 'pn_brp2_12', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 21, - 'alias': 'linear_45', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_13', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 22, - 'alias': 'linear_46', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_2', - 'target_component': 'pn_brp2_14', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 23, - 'alias': 'linear_47', - 'source_component': 'dynamic_mechanical_node_alternative_3', - 'source_port': 'port_1', - 'target_component': 'pn_brp2_15', - 'target_port': 'port_3', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 24, - 'alias': 'pneumatic_56', - 'source_component': 'pn_c1_8', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_8', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 25, - 'alias': 'pneumatic_57', - 'source_component': 'pn_c1_9', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_9', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 26, - 'alias': 'pneumatic_58', - 'source_component': 'pn_c1_10', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_10', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 27, - 'alias': 'pneumatic_59', - 'source_component': 'pn_c1_11', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_11', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 28, - 'alias': 'pneumatic_60', - 'source_component': 'pn_c1_12', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_12', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 29, - 'alias': 'pneumatic_61', - 'source_component': 'pn_c1_13', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_13', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 30, - 'alias': 'pneumatic_62', - 'source_component': 'pn_c1_14', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_14', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 31, - 'alias': 'pneumatic_63', - 'source_component': 'pn_c1_15', - 'source_port': 'port_3', - 'target_component': 'pn_brp2_15', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 32, - 'alias': 'pneumatic_64', - 'source_component': 'pn_morifice_15', - 'source_port': 'port_2', - 'target_component': 'pnnode4_19', - 'target_port': 'port_4', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 33, - 'alias': 'pneumatic_65', - 'source_component': 'pnnode4_19', - 'source_port': 'port_2', - 'target_component': 'pn_c1_11', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 34, - 'alias': 'pneumatic_66', - 'source_component': 'pnnode4_18', - 'source_port': 'port_2', - 'target_component': 'pn_c1_10', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 35, - 'alias': 'pneumatic_67', - 'source_component': 'pn_morifice_10', - 'source_port': 'port_2', - 'target_component': 'pnnode4_18', - 'target_port': 'port_4', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 36, - 'alias': 'pneumatic_68', - 'source_component': 'pnnode4_17', - 'source_port': 'port_2', - 'target_component': 'pn_c1_9', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 37, - 'alias': 'pneumatic_69', - 'source_component': 'pnnode4_16', - 'source_port': 'port_2', - 'target_component': 'pn_c1_8', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 38, - 'alias': 'pneumatic_70', - 'source_component': 'pn_morifice_12', - 'source_port': 'port_2', - 'target_component': 'pnnode4_20', - 'target_port': 'port_4', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 39, - 'alias': 'pneumatic_71', - 'source_component': 'pnnode4_23', - 'source_port': 'port_2', - 'target_component': 'pn_c1_12', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 40, - 'alias': 'pneumatic_72', - 'source_component': 'pnnode4_20', - 'source_port': 'port_2', - 'target_component': 'pn_c1_13', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 41, - 'alias': 'pneumatic_73', - 'source_component': 'pnnode4_21', - 'source_port': 'port_2', - 'target_component': 'pn_c1_14', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 42, - 'alias': 'pneumatic_74', - 'source_component': 'pnnode4_22', - 'source_port': 'port_2', - 'target_component': 'pn_c1_15', - 'target_port': 'port_1', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 43, - 'alias': 'pneumatic_75', - 'source_component': 'pn_node3_10', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_10', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 44, - 'alias': 'pneumatic_76', - 'source_component': 'pn_node3_11', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_15', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 45, - 'alias': 'pneumatic_77', - 'source_component': 'pn_node3_12', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_11', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 46, - 'alias': 'pneumatic_78', - 'source_component': 'pn_node3_13', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_12', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 47, - 'alias': 'pneumatic_79', - 'source_component': 'pnnode4_23', - 'source_port': 'port_4', - 'target_component': 'pn_morifice_11', - 'target_port': 'port_2', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 48, - 'alias': 'pneumatic_80', - 'source_component': 'pnnode4_18', - 'source_port': 'port_1', - 'target_component': 'pnnode4_19', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 49, - 'alias': 'pneumatic_81', - 'source_component': 'pnnode4_23', - 'source_port': 'port_1', - 'target_component': 'pnnode4_20', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 50, - 'alias': 'pneumatic_82', - 'source_component': 'pnnode4_21', - 'source_port': 'port_1', - 'target_component': 'pnnode4_22', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 51, - 'alias': 'pneumatic_83', - 'source_component': 'pnnode4_16', - 'source_port': 'port_1', - 'target_component': 'pnnode4_17', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 52, - 'alias': 'pneumatic_84', - 'source_component': 'pnnode4_20', - 'source_port': 'port_1', - 'target_component': 'pnnode4_21', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 53, - 'alias': 'pneumatic_85', - 'source_component': 'pnnode4_19', - 'source_port': 'port_1', - 'target_component': 'pnnode4_23', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 54, - 'alias': 'pneumatic_86', - 'source_component': 'pnnode4_17', - 'source_port': 'port_1', - 'target_component': 'pnnode4_18', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 55, - 'alias': 'pneumatic_87', - 'source_component': 'pn_node3_8', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_1', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 56, - 'alias': 'pneumatic_88', - 'source_component': 'pn_node3_9', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_9', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 57, - 'alias': 'pneumatic_89', - 'source_component': 'pn_morifice_1', - 'source_port': 'port_2', - 'target_component': 'pnnode4_16', - 'target_port': 'port_4', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 58, - 'alias': 'pneumatic_90', - 'source_component': 'pn_morifice_9', - 'source_port': 'port_2', - 'target_component': 'pnnode4_17', - 'target_port': 'port_4', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 59, - 'alias': 'pneumatic_91', - 'source_component': 'pn_node3_14', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_13', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 60, - 'alias': 'pneumatic_92', - 'source_component': 'pn_node3_15', - 'source_port': 'port_2', - 'target_component': 'pn_morifice_14', - 'target_port': 'port_3', - 'submodel': 'PNL0003', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R-C)'}, - {'index': 61, - 'alias': 'pneumatic_93', - 'source_component': 'pn_morifice_13', - 'source_port': 'port_2', - 'target_component': 'pnnode4_21', - 'target_port': 'port_4', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 62, - 'alias': 'pneumatic_94', - 'source_component': 'pn_morifice_14', - 'source_port': 'port_2', - 'target_component': 'pnnode4_22', - 'target_port': 'port_4', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 63, - 'alias': 'pneumatic_95', - 'source_component': 'pnnode4_22', - 'source_port': 'port_1', - 'target_component': 'pnnode4_16', - 'target_port': 'port_3', - 'submodel': 'PNL0002', - 'label': 'Compressibility + friction submodel of pneumatic pipe (R-C-R)'}, - {'index': 64, - 'alias': 'pneumatic_96', - 'source_component': 'pn_node3_8', - 'source_port': 'port_1', - 'target_component': 'pn_orifice_18', - 'target_port': 'port_2', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 65, - 'alias': 'pneumatic_97', - 'source_component': 'pn_orifice_19', - 'source_port': 'port_1', - 'target_component': 'pn_node3_9', - 'target_port': 'port_3', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 66, - 'alias': 'pneumatic_98', - 'source_component': 'pn_node3_10', - 'source_port': 'port_1', - 'target_component': 'pn_orifice_20', - 'target_port': 'port_2', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 67, - 'alias': 'pneumatic_99', - 'source_component': 'pn_orifice_21', - 'source_port': 'port_1', - 'target_component': 'pn_node3_11', - 'target_port': 'port_3', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 68, - 'alias': 'pneumatic_100', - 'source_component': 'pn_node3_9', - 'source_port': 'port_1', - 'target_component': 'pn_node3_10', - 'target_port': 'port_3', - 'submodel': 'PNL00R', - 'label': 'Friction submodel of pneumatic pipe (R)'}, - {'index': 69, - 'alias': 'pneumatic_101', - 'source_component': 'pn_node3_12', - 'source_port': 'port_1', - 'target_component': 'pn_orifice_22', - 'target_port': 'port_2', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 70, - 'alias': 'pneumatic_102', - 'source_component': 'pn_orifice_23', - 'source_port': 'port_1', - 'target_component': 'pn_node3_13', - 'target_port': 'port_3', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 71, - 'alias': 'pneumatic_103', - 'source_component': 'pn_node3_14', - 'source_port': 'port_1', - 'target_component': 'pn_orifice_24', - 'target_port': 'port_2', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 72, - 'alias': 'pneumatic_104', - 'source_component': 'pn_orifice_25', - 'source_port': 'port_1', - 'target_component': 'pn_node3_15', - 'target_port': 'port_3', - 'submodel': 'PNL0001', - 'label': 'Compressibility + friction submodel of pneumatic pipe (C-R)'}, - {'index': 73, - 'alias': 'pneumatic_105', - 'source_component': 'pn_node3_8', - 'source_port': 'port_3', - 'target_component': 'pn_node3_15', - 'target_port': 'port_1', - 'submodel': 'PNL00R', - 'label': 'Friction submodel of pneumatic pipe (R)'}, - {'index': 74, - 'alias': 'pneumatic_106', - 'source_component': 'pn_node3_13', - 'source_port': 'port_1', - 'target_component': 'pn_node3_14', - 'target_port': 'port_3', - 'submodel': 'PNL00R', - 'label': 'Friction submodel of pneumatic pipe (R)'}, - {'index': 75, - 'alias': 'pneumatic_107', - 'source_component': 'pn_node3_11', - 'source_port': 'port_1', - 'target_component': 'pn_node3_12', - 'target_port': 'port_3', - 'submodel': 'PNL00R', - 'label': 'Friction submodel of pneumatic pipe (R)'}, - {'index': 76, - 'alias': 'control_8', - 'source_component': 'step_8', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_11', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 77, - 'alias': 'control_9', - 'source_component': 'step_9', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_12', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 78, - 'alias': 'control_10', - 'source_component': 'step_10', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_13', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 79, - 'alias': 'control_11', - 'source_component': 'step_11', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_14', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 80, - 'alias': 'control_12', - 'source_component': 'step_12', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_1', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 81, - 'alias': 'control_13', - 'source_component': 'step_13', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_9', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 82, - 'alias': 'control_14', - 'source_component': 'step_14', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_10', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}, - {'index': 83, - 'alias': 'control_15', - 'source_component': 'step_15', - 'source_port': 'port_1', - 'target_component': 'pn_morifice_15', - 'target_port': 'port_1', - 'submodel': 'DIRECT', - 'label': 'Direct connection'}] - - -@dataclass(frozen=True) -class TestMqlRunConfig: - t_start: float = SIMULATION_SETTINGS["t_start"] - t_stop: float = SIMULATION_SETTINGS["t_stop"] - sample_step: float = SIMULATION_SETTINGS["sample_step"] - - def sample_times(self) -> list[float]: - if self.sample_step <= 0.0: - raise ValueError("sample_step must be positive") - point_count = int(round((self.t_stop - self.t_start) / self.sample_step)) - return [self.t_start + index * self.sample_step for index in range(point_count + 1)] - - -@dataclass(frozen=True) -class TestMqlSnapshot: - model_name: str - component_count: int - connection_count: int - continuous_state_count: int - discrete_state_count: int - global_parameters: dict[str, str] - submodel_counts: dict[str, int] - - -@dataclass(frozen=True) -class TestMqlSimulationResult: - t: list[float] - y: list[list[float]] - series: dict[str, list[float]] - - -@dataclass(frozen=True) -class TestMqlFullStateSnapshot: - pneumatic: object - mechanical: object - mechanical_node_total_force_by_alias: dict[str, float] - - @property - def state_count(self) -> int: - return self.pneumatic_state_count + self.mechanical_state_count - - @property - def pneumatic_state_count(self) -> int: - return 112 - - @property - def mechanical_state_count(self) -> int: - return self.mechanical.state_count - - -@dataclass(frozen=True) -class TestMqlVariableChamberRhsDiagnostic: - chamber_alias: str - piston_alias: str - chamber_pressure_pa: float - chamber_temperature_k: float - chamber_volume_m3: float - chamber_volume_rate_m3_s: float - port_a_mass_flow_kg_s: float - port_b_mass_flow_kg_s: float - mass_derivative_kg_s: float - port_a_energy_flow_w: float - port_b_energy_flow_w: float - boundary_work_w: float - thermal_energy_flow_w: float - energy_derivative_w: float - - -@dataclass(frozen=True) -class TestMqlPnl0001LineRhsDiagnostic: - line_alias: str - chamber_alias: str - line_pressure_pa: float - line_temperature_k: float - chamber_pressure_pa: float - chamber_temperature_k: float - chamber_to_line_flow_kg_s: float - node_to_line_flow_kg_s: float - mass_derivative_kg_s: float - port_1_energy_flow_w: float - port_2_energy_flow_w: float - thermal_energy_flow_w: float - energy_derivative_w: float - - -_PISTON_FORCE_BINDINGS = ( - ("mass_friction_endstops_10", "pn_brp2_8", "p4_port3_remote_primary_chamber"), - ("mass_friction_endstops_11", "pn_brp2_9", "p4_primary_chamber"), - ("mass_friction_endstops_12", "pn_brp2_10", "p4_port1_remote_primary_chamber"), - ("mass_friction_endstops_13", "pn_brp2_11", "p4_port1_far_primary_chamber"), - ("mass_friction_endstops_14", "pn_brp2_12", "p4_port1_next_primary_chamber"), - ("mass_friction_endstops_15", "pn_brp2_13", "p4_bridge_primary_chamber"), - ("mass_friction_endstops_16", "pn_brp2_14", "p4_port3_next_primary_chamber"), - ("mass_friction_endstops_17", "pn_brp2_15", "p4_port3_far_primary_chamber"), -) - -_ELASTIC_ENDSTOP_FORCE_BINDINGS = ( - ("mass_friction_endstops_10", "mass_friction_endstops_18", "elasticendstop_8"), - ("mass_friction_endstops_11", "mass_friction_endstops_18", "elasticendstop_9"), - ("mass_friction_endstops_12", "mass_friction_endstops_18", "elasticendstop_10"), - ("mass_friction_endstops_13", "mass_friction_endstops_18", "elasticendstop_11"), - ("mass_friction_endstops_14", "mass_friction_endstops_18", "elasticendstop_12"), - ("mass_friction_endstops_15", "mass_friction_endstops_18", "elasticendstop_13"), - ("mass_friction_endstops_16", "mass_friction_endstops_18", "elasticendstop_14"), - ("mass_friction_endstops_17", "mass_friction_endstops_18", "elasticendstop_15"), -) - - -def _default_full_state_data_paths() -> tuple[str, ...]: - chamber_paths: list[str] = [] - piston_paths: list[str] = [] - for _mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: - chamber_alias = _PNCH012_ALIAS_BY_SNAPSHOT_FIELD[chamber_field] - chamber_paths.extend([f"press@{chamber_alias}", f"vol@{chamber_alias}"]) - piston_paths.extend([f"vol1@{piston_alias}", f"vvol1@{piston_alias}"]) - mass_paths = [ - f"{signal}@mass_friction_endstops_{index}" - for index in range(10, 20) - for signal in ("x1", "v1", "acc1") - ] - return tuple( - [ - *chamber_paths, - *piston_paths, - *mass_paths, - "dm1@pneumatic_69", - "xv@pn_morifice_1", - "dm2@pn_morifice_1", - ] - ) - - -_PNCH012_ALIAS_BY_SNAPSHOT_FIELD = { - "p4_port3_remote_primary_chamber": "pn_c1_8", - "p4_primary_chamber": "pn_c1_9", - "p4_port1_remote_primary_chamber": "pn_c1_10", - "p4_port1_far_primary_chamber": "pn_c1_11", - "p4_port1_next_primary_chamber": "pn_c1_12", - "p4_bridge_primary_chamber": "pn_c1_13", - "p4_port3_next_primary_chamber": "pn_c1_14", - "p4_port3_far_primary_chamber": "pn_c1_15", -} - - -_PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS = { - "pn_c1_8": ( - "p4_port3_remote_primary_chamber", - "p4_port3_remote_primary_line", - "p4_port3_remote_chamber_to_line_flow", - ), - "pn_c1_9": ( - "p4_primary_chamber", - "p4_primary_line", - "p4_primary_chamber_to_line_flow", - ), - "pn_c1_10": ( - "p4_port1_remote_primary_chamber", - "p4_port1_remote_primary_line", - "p4_port1_remote_chamber_to_line_flow", - ), - "pn_c1_11": ( - "p4_port1_far_primary_chamber", - "p4_port1_far_primary_line", - "p4_port1_far_chamber_to_line_flow", - ), - "pn_c1_12": ( - "p4_port1_next_primary_chamber", - "p4_port1_next_primary_line", - "p4_port1_next_chamber_to_line_flow", - ), - "pn_c1_13": ( - "p4_bridge_primary_chamber", - "p4_bridge_primary_line", - "p4_bridge_chamber_to_line_flow", - ), - "pn_c1_14": ( - "p4_port3_next_primary_chamber", - "p4_port3_next_primary_line", - "p4_port3_next_chamber_to_line_flow", - ), - "pn_c1_15": ( - "p4_port3_far_primary_chamber", - "p4_port3_far_primary_line", - "p4_port3_far_chamber_to_line_flow", - ), -} - - -_PNL0001_LINE_DIAGNOSTIC_FIELDS = { - "pneumatic_69": ( - "p4_port3_remote_primary_line", - "p4_port3_remote_primary_chamber", - "p4_port3_remote_chamber_to_line_flow", - "p4_port3_remote_node_to_primary_line_flow", - ), -} - -_VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS = { - "pn_morifice_1": "p4_port3_remote_orifice_to_node_flow", -} - - -class TestMqlFullStateClosure: - def __init__( - self, - *, - pneumatic_closure: object, - mechanical_closure: object, - pneumatic_assembly: object, - ) -> None: - self.pneumatic_closure = pneumatic_closure - self.mechanical_closure = mechanical_closure - self.pneumatic_assembly = pneumatic_assembly - self.pneumatic_state_count = len(pneumatic_closure.initial_state_vector()) - self.mechanical_state_count = len(mechanical_closure.initial_state_vector()) - - @property - def state_count(self) -> int: - return self.pneumatic_state_count + self.mechanical_state_count - - def initial_state_vector(self) -> list[float]: - return [ - *self.pneumatic_closure.initial_state_vector(), - *self.mechanical_closure.initial_state_vector(), - ] - - def _split_state(self, state_vector: list[float]) -> tuple[list[float], list[float]]: - if len(state_vector) != self.state_count: - raise ValueError("full test_mql state vector requires 132 values") - return ( - state_vector[: self.pneumatic_state_count], - state_vector[self.pneumatic_state_count :], - ) - - def snapshot(self, state_vector: list[float] | None = None) -> TestMqlFullStateSnapshot: - return self.snapshot_at(0.0, state_vector) - - def snapshot_at( - self, - time_s: float, - state_vector: list[float] | None = None, - ) -> TestMqlFullStateSnapshot: - values = self.initial_state_vector() if state_vector is None else list(state_vector) - pneumatic_state, mechanical_state = self._split_state(values) - mechanical_snapshot = self.mechanical_closure.snapshot(mechanical_state) - self._sync_variable_chamber_kinematics(mechanical_snapshot) - self.pneumatic_assembly.set_variable_orifice_openings(time_s) - pneumatic_snapshot = self.pneumatic_closure.snapshot(pneumatic_state) - return TestMqlFullStateSnapshot( - pneumatic=pneumatic_snapshot, - mechanical=mechanical_snapshot, - mechanical_node_total_force_by_alias=( - self._mechanical_node_total_force_by_alias( - pneumatic_snapshot, - mechanical_snapshot, - ) - ), - ) - - def rhs(self, state_vector: list[float]) -> list[float]: - return self.rhs_at(0.0, state_vector) - - def rhs_at(self, time_s: float, state_vector: list[float]) -> list[float]: - pneumatic_state, mechanical_state = self._split_state(list(state_vector)) - mechanical_snapshot = self.mechanical_closure.snapshot(mechanical_state) - self._sync_variable_chamber_kinematics(mechanical_snapshot) - self.pneumatic_assembly.set_variable_orifice_openings(time_s) - pneumatic_snapshot = self.pneumatic_closure.snapshot(pneumatic_state) - return [ - *self.pneumatic_closure.rhs(pneumatic_state), - *self.mechanical_closure.rhs( - mechanical_state, - force_by_mass_alias=self._force_by_mass_alias( - time_s, - pneumatic_snapshot, - mechanical_snapshot, - ), - constrained_mass_aliases={ - "mass_friction_endstops_18", - "mass_friction_endstops_19", - }, - ), - ] - - def _sync_variable_chamber_kinematics(self, mechanical_snapshot: object) -> None: - for _mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: - piston = self.mechanical_closure.assembly.pistons[piston_alias] - kinematics = mechanical_snapshot.piston_kinematics_by_alias[piston_alias] - chamber = getattr(self.pneumatic_closure.components, chamber_field) - chamber.set_external_volume_m3( - piston.geometry().chamber_volume_m3( - kinematics.port_3_displacement_m, - kinematics.port_2_displacement_m, - ), - piston.geometry().chamber_volume_rate_m3_s( - kinematics.port_3_velocity_m_s, - kinematics.port_2_velocity_m_s, - ), - ) - - def variable_chamber_rhs_diagnostic( - self, - *, - chamber_alias: str, - state_vector: list[float], - time_s: float = 0.0, - ) -> TestMqlVariableChamberRhsDiagnostic: - if chamber_alias not in _PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS: - raise KeyError(chamber_alias) - chamber_field, line_field, flow_field = _PRIMARY_CHAMBER_DIAGNOSTIC_FIELDS[ - chamber_alias - ] - piston_alias = next( - piston_alias - for _mass_alias, piston_alias, bound_chamber_field in _PISTON_FORCE_BINDINGS - if bound_chamber_field == chamber_field - ) - snapshot = self.snapshot_at(time_s, state_vector) - chamber = getattr(self.pneumatic_closure.components, chamber_field) - chamber_properties = getattr(snapshot.pneumatic, chamber_field) - line_properties = getattr(snapshot.pneumatic, line_field) - chamber_to_line_flow = getattr(snapshot.pneumatic, flow_field) - port_a_mass_flow = -chamber_to_line_flow - port_b_mass_flow = 0.0 - port_a_inlet_h = chamber.connection_inlet_enthalpy( - port_m_flow=port_a_mass_flow, - connected_h=line_properties.h, - internal_h=chamber_properties.h, - ) - port_b_inlet_h = chamber.connection_inlet_enthalpy( - port_m_flow=port_b_mass_flow, - connected_h=chamber_properties.h, - internal_h=chamber_properties.h, - ) - port_a_energy_flow = port_a_mass_flow * port_a_inlet_h - port_b_energy_flow = port_b_mass_flow * port_b_inlet_h - boundary_work = -chamber_properties.p * chamber.volume_rate_m3_s() - thermal_energy_flow = chamber.thermal_energy_flow_w( - chamber_properties.T - ) - return TestMqlVariableChamberRhsDiagnostic( - chamber_alias=chamber_alias, - piston_alias=piston_alias, - chamber_pressure_pa=chamber_properties.p, - chamber_temperature_k=chamber_properties.T, - chamber_volume_m3=chamber.volume, - chamber_volume_rate_m3_s=chamber.volume_rate_m3_s(), - port_a_mass_flow_kg_s=port_a_mass_flow, - port_b_mass_flow_kg_s=port_b_mass_flow, - mass_derivative_kg_s=port_a_mass_flow + port_b_mass_flow, - port_a_energy_flow_w=port_a_energy_flow, - port_b_energy_flow_w=port_b_energy_flow, - boundary_work_w=boundary_work, - thermal_energy_flow_w=thermal_energy_flow, - energy_derivative_w=( - port_a_energy_flow - + port_b_energy_flow - + boundary_work - + thermal_energy_flow - ), - ) - - def pnl0001_line_rhs_diagnostic( - self, - *, - line_alias: str, - state_vector: list[float], - time_s: float = 0.0, - ) -> TestMqlPnl0001LineRhsDiagnostic: - if line_alias not in _PNL0001_LINE_DIAGNOSTIC_FIELDS: - raise KeyError(line_alias) - ( - line_field, - chamber_field, - chamber_flow_field, - node_flow_field, - ) = _PNL0001_LINE_DIAGNOSTIC_FIELDS[line_alias] - chamber_alias = _PNCH012_ALIAS_BY_SNAPSHOT_FIELD[chamber_field] - snapshot = self.snapshot_at(time_s, state_vector) - line = getattr(self.pneumatic_closure.components, line_field) - line_properties = getattr(snapshot.pneumatic, line_field) - chamber_properties = getattr(snapshot.pneumatic, chamber_field) - chamber_to_line_flow = getattr(snapshot.pneumatic, chamber_flow_field) - node_to_line_flow = getattr(snapshot.pneumatic, node_flow_field) - p4_balance = snapshot.pneumatic.p4_port3_remote_balance - p4_port_2_mass_flow_kg_s = p4_balance.port_2_mass_flow_g_s * 1.0e-3 - node_connected_h = ( - line_properties.h - if abs(p4_port_2_mass_flow_kg_s) <= 1.0e-12 - else p4_balance.port_2_enthalpy_flow_w / p4_port_2_mass_flow_kg_s - ) - line_derivative = line.derivatives_from_transport_enthalpy_connections( - port_1_m_flow=chamber_to_line_flow, - connected_h_1=chamber_properties.h, - port_2_m_flow=node_to_line_flow, - connected_h_2=node_connected_h, - ) - port_1_inlet_h = ( - chamber_properties.h if chamber_to_line_flow > 0.0 else line_properties.h - ) - port_2_inlet_h = ( - node_connected_h if node_to_line_flow > 0.0 else line_properties.h - ) - thermal_energy_flow = ( - line.heat_transfer_coefficient - * line.heat_transfer_area - * (line.external_temperature - line_properties.T) - ) - return TestMqlPnl0001LineRhsDiagnostic( - line_alias=line_alias, - chamber_alias=chamber_alias, - line_pressure_pa=line_properties.p, - line_temperature_k=line_properties.T, - chamber_pressure_pa=chamber_properties.p, - chamber_temperature_k=chamber_properties.T, - chamber_to_line_flow_kg_s=chamber_to_line_flow, - node_to_line_flow_kg_s=node_to_line_flow, - mass_derivative_kg_s=line_derivative.m, - port_1_energy_flow_w=chamber_to_line_flow * port_1_inlet_h, - port_2_energy_flow_w=node_to_line_flow * port_2_inlet_h, - thermal_energy_flow_w=thermal_energy_flow, - energy_derivative_w=line_derivative.U, - ) - - def data_path_values( - self, - *, - time_s: float, - state_vector: list[float], - data_paths: tuple[str, ...] | list[str] | None = None, - ) -> dict[str, float]: - from app.simulation.examples.test_mql.pneumatic import pressure_to_amesim_gauge_pa - - selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths() - snapshot = self.snapshot_at(time_s, state_vector) - rhs = self.rhs_at(time_s, state_vector) - chamber_properties_by_alias = {} - chamber_by_alias = {} - for _mass_alias, _piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: - chamber = getattr(self.pneumatic_closure.components, chamber_field) - chamber_by_alias[chamber.name] = chamber - chamber_properties_by_alias[chamber.name] = getattr(snapshot.pneumatic, chamber_field) - - state_by_alias = {state.alias: state for state in snapshot.mechanical.states} - acceleration_by_mass_alias = { - alias: rhs[self.pneumatic_state_count + 2 * index] - for index, alias in enumerate(self.mechanical_closure.mass_aliases) - } - values_by_data_path = {} - for data_path in selected_paths: - signal, alias = self._split_data_path(data_path) - if alias in _PNL0001_LINE_DIAGNOSTIC_FIELDS: - ( - line_field, - _chamber_field, - chamber_flow_field, - _node_flow_field, - ) = _PNL0001_LINE_DIAGNOSTIC_FIELDS[alias] - if signal == "dm1": - canonical_flow_kg_s = getattr(snapshot.pneumatic, chamber_flow_field) - values_by_data_path[data_path] = -canonical_flow_kg_s * 1.0e3 - elif signal == "p2": - line_properties = getattr(snapshot.pneumatic, line_field) - values_by_data_path[data_path] = pressure_to_amesim_gauge_pa( - line_properties.p - ) - elif signal == "t2": - line_properties = getattr(snapshot.pneumatic, line_field) - values_by_data_path[data_path] = line_properties.T - elif signal == "mgas": - line = getattr(self.pneumatic_closure.components, line_field) - values_by_data_path[data_path] = line.gas_mass_g() - elif signal in {"re", "v", "ff"}: - line = getattr(self.pneumatic_closure.components, line_field) - line_properties = getattr(snapshot.pneumatic, line_field) - canonical_flow_kg_s = getattr(snapshot.pneumatic, chamber_flow_field) - diagnostics = line.diagnostics( - mass_flow_kg_s=-canonical_flow_kg_s, - temperature_k=line_properties.T, - ) - if signal == "re": - values_by_data_path[data_path] = diagnostics.reynolds_number - elif signal == "v": - values_by_data_path[data_path] = diagnostics.gas_velocity_m_s - else: - values_by_data_path[data_path] = diagnostics.friction_factor - else: - raise KeyError(data_path) - elif alias in self.pneumatic_assembly.variable_orifices: - orifice = self.pneumatic_assembly.variable_orifices[alias] - if signal == "xv": - values_by_data_path[data_path] = orifice.opening - elif ( - signal == "dm2" - and alias in _VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS - ): - flow_kg_s = getattr( - snapshot.pneumatic, - _VARIABLE_ORIFICE_MASS_FLOW_DIAGNOSTIC_FIELDS[alias], - ) - values_by_data_path[data_path] = flow_kg_s * 1.0e3 - else: - raise KeyError(data_path) - elif alias in chamber_properties_by_alias: - chamber_properties = chamber_properties_by_alias[alias] - chamber = chamber_by_alias[alias] - if signal == "press": - values_by_data_path[data_path] = pressure_to_amesim_gauge_pa( - chamber_properties.p - ) - elif signal == "temp": - values_by_data_path[data_path] = chamber_properties.T - elif signal == "vol": - values_by_data_path[data_path] = chamber.volume_cm3() - elif signal in {"mgas", "mgas1"}: - values_by_data_path[data_path] = chamber.gas_mass_g() - else: - raise KeyError(data_path) - elif alias in self.mechanical_closure.assembly.pistons: - piston = self.mechanical_closure.assembly.pistons[alias] - kinematics = snapshot.mechanical.piston_kinematics_by_alias[alias] - geometry = piston.geometry() - if signal == "vol1": - values_by_data_path[data_path] = geometry.chamber_volume_cm3( - kinematics.port_3_displacement_m, - kinematics.port_2_displacement_m, - ) - elif signal == "vvol1": - values_by_data_path[data_path] = geometry.chamber_volume_rate_l_min( - kinematics.port_3_velocity_m_s, - kinematics.port_2_velocity_m_s, - ) - elif signal == "length": - values_by_data_path[data_path] = geometry.chamber_length_mm( - kinematics.port_3_displacement_m, - kinematics.port_2_displacement_m, - ) - elif signal == "x4": - values_by_data_path[data_path] = kinematics.port_3_displacement_m - elif signal == "x5": - values_by_data_path[data_path] = kinematics.port_2_displacement_m - elif signal == "v4": - values_by_data_path[data_path] = kinematics.port_3_velocity_m_s - elif signal == "v5": - values_by_data_path[data_path] = kinematics.port_2_velocity_m_s - else: - raise KeyError(data_path) - elif alias in state_by_alias: - mass_state = state_by_alias[alias] - if signal == "x1": - values_by_data_path[data_path] = mass_state.displacement_m - elif signal == "v1": - values_by_data_path[data_path] = mass_state.velocity_m_s - elif signal == "acc1": - values_by_data_path[data_path] = acceleration_by_mass_alias[alias] - else: - raise KeyError(data_path) - else: - raise KeyError(data_path) - return values_by_data_path - - def data_path_series( - self, - *, - times: tuple[float, ...] | list[float], - state_rows: list[list[float]], - data_paths: tuple[str, ...] | list[str] | None = None, - ) -> dict[str, list[float]]: - selected_paths = tuple(data_paths) if data_paths is not None else _default_full_state_data_paths() - series = {data_path: [] for data_path in selected_paths} - for sample_index, time_value in enumerate(times): - state_vector = [row[sample_index] for row in state_rows] - values = self.data_path_values( - time_s=float(time_value), - state_vector=state_vector, - data_paths=selected_paths, - ) - for data_path in selected_paths: - series[data_path].append(values[data_path]) - return series - - @staticmethod - def _split_data_path(data_path: str) -> tuple[str, str]: - if "@" not in data_path: - raise KeyError(data_path) - signal, alias = data_path.split("@", 1) - return signal, alias - - def _force_by_mass_alias( - self, - time_s: float, - pneumatic_snapshot: object, - mechanical_snapshot: object, - ) -> dict[str, float]: - force_by_mass_alias = self._piston_force_by_mass_alias(pneumatic_snapshot) - elastic_force_by_mass_alias = self._elastic_endstop_force_by_mass_alias( - mechanical_snapshot - ) - for mass_alias, contact_force in elastic_force_by_mass_alias.items(): - force_by_mass_alias[mass_alias] = ( - force_by_mass_alias.get(mass_alias, 0.0) - contact_force - ) - force_by_mass_alias.update( - self._large_mass_force_by_alias( - time_s, - pneumatic_snapshot, - mechanical_snapshot, - ) - ) - return force_by_mass_alias - - def _mechanical_node_total_force_by_alias( - self, - pneumatic_snapshot: object, - mechanical_snapshot: object, - ) -> dict[str, float]: - return { - "dynamic_mechanical_node_alternative_2": sum( - self._elastic_endstop_force_by_mass_alias(mechanical_snapshot).values() - ), - "dynamic_mechanical_node_alternative_3": sum( - self._piston_force_by_mass_alias(pneumatic_snapshot).values() - ), - } - - def _large_mass_force_by_alias( - self, - time_s: float, - pneumatic_snapshot: object, - mechanical_snapshot: object, - ) -> dict[str, float]: - node_force = self._mechanical_node_total_force_by_alias( - pneumatic_snapshot, - mechanical_snapshot, - ) - forcecon_1 = self.mechanical_closure.assembly.force_connectors["forcecon_1"] - forcecon_2 = self.mechanical_closure.assembly.force_connectors["forcecon_2"] - signals = self.mechanical_closure.assembly.piecewise_signals - return { - "mass_friction_endstops_18": ( - node_force["dynamic_mechanical_node_alternative_2"] - - forcecon_2.force_at(time_s, signals) - ), - "mass_friction_endstops_19": ( - forcecon_1.force_at(time_s, signals) - - node_force["dynamic_mechanical_node_alternative_3"] - ), - } - - def _piston_force_by_mass_alias(self, pneumatic_snapshot: object) -> dict[str, float]: - from app.simulation.examples.test_mql.pneumatic import pressure_to_amesim_gauge_pa - - force_by_mass_alias: dict[str, float] = {} - for mass_alias, piston_alias, chamber_field in _PISTON_FORCE_BINDINGS: - chamber = getattr(pneumatic_snapshot, chamber_field) - piston = self.mechanical_closure.assembly.pistons[piston_alias] - force_by_mass_alias[mass_alias] = ( - pressure_to_amesim_gauge_pa(chamber.p) * piston.annulus_area_m2 - ) - return force_by_mass_alias - - def _elastic_endstop_force_by_mass_alias( - self, - mechanical_snapshot: object, - ) -> dict[str, float]: - state_by_alias = {state.alias: state for state in mechanical_snapshot.states} - force_by_mass_alias: dict[str, float] = {} - for mass_alias, support_mass_alias, endstop_alias in _ELASTIC_ENDSTOP_FORCE_BINDINGS: - mass_state = state_by_alias[mass_alias] - support_state = state_by_alias[support_mass_alias] - endstop = self.mechanical_closure.assembly.elastic_endstops[ - endstop_alias - ].endstop() - gap_mm = (support_state.displacement_m - mass_state.displacement_m) * 1.0e3 - penetration_velocity_m_s = ( - mass_state.velocity_m_s - support_state.velocity_m_s - ) - force_by_mass_alias[mass_alias] = endstop.contact_force( - gap_mm, - penetration_velocity_m_s, - ) - return force_by_mass_alias - - -class AmesimStructuralComponent(Component): - def __init__(self, spec: dict[str, object]) -> None: - super().__init__(name=str(spec["alias"])) - self.spec = spec - self.submodel = str(spec["submodel"]) - self.component_name = str(spec["component_name"]) - self.parameters = { - str(parameter["name"]): parameter - for parameter in spec.get("parameters", []) - } - - @property - def port_names(self) -> tuple[str, ...]: - return tuple(str(port["name"]) for port in self.spec.get("ports", [])) - - -class TestMqlSystem: - """Structural Python port scaffold for AMESim model ``test_mql``.""" - - def __init__(self, archive_path: Path | None = None) -> None: - self.archive_path = archive_path or AMESIM_TEST_MQL_ARCHIVE_PATH - self.network = SimulationNetwork(name=MODEL_NAME) - self.pnl0001_assembly = self._build_pnl0001_assembly() - self.pnl0002_assembly = self._build_pnl0002_assembly() - self.pnl0003_assembly = self._build_pnl0003_assembly() - self.pnl00r_assembly = self._build_pnl00r_assembly() - self.node3_assembly = self._build_node3_assembly() - self.node4_assembly = self._build_node4_assembly() - self.mechanical_assembly = self._build_mechanical_assembly() - self.pneumatic_assembly = self._build_pneumatic_assembly() - pneumatic_components = self._pneumatic_components_by_alias() - for spec in COMPONENT_SPECS: - alias = str(spec["alias"]) - self.network.add_component( - pneumatic_components.get(alias, AmesimStructuralComponent(spec)) - ) - for spec in CONNECTION_SPECS: - self.network.connect( - str(spec["source_component"]), - str(spec["source_port"]), - str(spec["target_component"]), - str(spec["target_port"]), - ) - - @staticmethod - def _build_pneumatic_assembly(): - # Local import avoids a module cycle: test_mql_config reads constants from this module. - from app.simulation.examples.test_mql.pneumatic import build_test_mql_pneumatic_assembly - - return build_test_mql_pneumatic_assembly() - - @staticmethod - def _build_mechanical_assembly(): - # Local import avoids a module cycle: test_mql_config reads constants from this module. - from app.simulation.examples.test_mql.mechanical import build_test_mql_mechanical_assembly - - return build_test_mql_mechanical_assembly() - - def _build_pnl0001_assembly(self): - from app.simulation.examples.test_mql.pneumatic_lines import ( - build_test_mql_pnl0001_assembly, - ) - - return build_test_mql_pnl0001_assembly(self.archive_path) - - def _build_pnl0002_assembly(self): - from app.simulation.examples.test_mql.pneumatic_lines import ( - build_test_mql_pnl0002_assembly, - ) - - return build_test_mql_pnl0002_assembly(self.archive_path) - - def _build_pnl0003_assembly(self): - from app.simulation.examples.test_mql.pneumatic_lines import ( - build_test_mql_pnl0003_assembly, - ) - - return build_test_mql_pnl0003_assembly(self.archive_path) - - def _build_pnl00r_assembly(self): - from app.simulation.examples.test_mql.pneumatic_lines import ( - build_test_mql_pnl00r_assembly, - ) - - return build_test_mql_pnl00r_assembly(self.archive_path) - - @staticmethod - def _build_node3_assembly(): - from app.simulation.examples.test_mql.nodes import build_test_mql_node3_assembly - - return build_test_mql_node3_assembly() - - @staticmethod - def _build_node4_assembly(): - from app.simulation.examples.test_mql.nodes import build_test_mql_node4_assembly - - return build_test_mql_node4_assembly() - - def _pneumatic_components_by_alias(self) -> dict[str, Component]: - return { - **self.pneumatic_assembly.fixed_chambers, - **self.pneumatic_assembly.variable_chambers, - **self.pneumatic_assembly.fixed_orifices, - **self.pneumatic_assembly.variable_orifices, - } - - @property - def typed_pneumatic_component_count(self) -> int: - return len(self._pneumatic_components_by_alias()) - - @property - def typed_pnl0001_line_count(self) -> int: - return len(self.pnl0001_assembly.lines) - - @property - def typed_pnl0002_line_count(self) -> int: - return len(self.pnl0002_assembly.lines) - - @property - def typed_pnl0003_line_count(self) -> int: - return len(self.pnl0003_assembly.lines) - - @property - def typed_pnl00r_line_count(self) -> int: - return len(self.pnl00r_assembly.lines) - - @property - def typed_node3_count(self) -> int: - return len(self.node3_assembly) - - @property - def typed_node4_count(self) -> int: - return len(self.node4_assembly) - - def _pnl0001_spec_for_node_port(self, node_alias: str, port_name: str): - for spec in self.pnl0001_assembly.specs: - if spec.source_component == node_alias and spec.source_port == port_name: - return spec - if spec.target_component == node_alias and spec.target_port == port_name: - return spec - raise KeyError(f"No PNL0001 line attached to {node_alias}.{port_name}") - - def _pnl0001_line_for_node_port(self, node_alias: str, port_name: str): - spec = self._pnl0001_spec_for_node_port(node_alias, port_name) - return self.pnl0001_assembly.lines[spec.alias] - - def _pnl0002_connection_for_node_port(self, node_alias: str, port_name: str): - from app.simulation.examples.test_mql.nodes import TestMqlP4NodePortConnection - - spec = self._pnl0002_spec_for_node_port(node_alias, port_name) - if spec.source_component == node_alias and spec.source_port == port_name: - remote_node_alias = spec.target_component - remote_port = spec.target_port - elif spec.target_component == node_alias and spec.target_port == port_name: - remote_node_alias = spec.source_component - remote_port = spec.source_port - else: - raise KeyError(f"No PNL0002 line attached to {node_alias}.{port_name}") - if remote_node_alias not in self.node4_assembly: - raise KeyError( - f"PNL0002 line {spec.alias} remote endpoint is not a P4 node: " - f"{remote_node_alias}.{remote_port}" - ) - return TestMqlP4NodePortConnection( - line_alias=spec.alias, - local_node_alias=node_alias, - local_port=port_name, - remote_node_alias=remote_node_alias, - remote_port=remote_port, - ) - - def _pnl0002_spec_for_node_port(self, node_alias: str, port_name: str): - for spec in self.pnl0002_assembly.specs: - if spec.source_component == node_alias and spec.source_port == port_name: - return spec - if spec.target_component == node_alias and spec.target_port == port_name: - return spec - raise KeyError(f"No PNL0002 line attached to {node_alias}.{port_name}") - - def _pnl0002_line_for_node_port(self, node_alias: str, port_name: str): - spec = self._pnl0002_spec_for_node_port(node_alias, port_name) - return self.pnl0002_assembly.lines[spec.alias] - - def _p4_primary_connection_for_node(self, node_alias: str): - from app.simulation.examples.test_mql.nodes import TestMqlP4NodePrimaryConnection - - spec = self._pnl0001_spec_for_node_port(node_alias, "port_2") - if spec.source_component == node_alias and spec.source_port == "port_2": - chamber_alias = spec.target_component - chamber_port = spec.target_port - elif spec.target_component == node_alias and spec.target_port == "port_2": - chamber_alias = spec.source_component - chamber_port = spec.source_port - else: - raise KeyError(f"No PNL0001 primary line attached to {node_alias}.port_2") - return TestMqlP4NodePrimaryConnection( - line_alias=spec.alias, - node_alias=node_alias, - node_port="port_2", - chamber_alias=chamber_alias, - chamber_port=chamber_port, - ) - - def _p4_port4_orifice_connection_for_node(self, node_alias: str): - from app.simulation.examples.test_mql.nodes import TestMqlP4NodeOrificeConnection - - for spec in CONNECTION_SPECS: - if spec["submodel"] != "DIRECT": - continue - if spec["target_component"] == node_alias and spec["target_port"] == "port_4": - orifice_alias = str(spec["source_component"]) - if orifice_alias in self.pneumatic_assembly.variable_orifices: - return TestMqlP4NodeOrificeConnection( - orifice_alias=orifice_alias, - node_alias=node_alias, - node_port="port_4", - direct_line_alias=str(spec["alias"]), - ) - if spec["source_component"] == node_alias and spec["source_port"] == "port_4": - orifice_alias = str(spec["target_component"]) - if orifice_alias in self.pneumatic_assembly.variable_orifices: - return TestMqlP4NodeOrificeConnection( - orifice_alias=orifice_alias, - node_alias=node_alias, - node_port="port_4", - direct_line_alias=str(spec["alias"]), - ) - raise KeyError(f"No PNVO orifice attached to {node_alias}.port_4") - - def p4_node_neighborhood(self, node_alias: str): - from app.simulation.examples.test_mql.nodes import TestMqlP4NodeNeighborhood - - if node_alias not in self.node4_assembly: - raise KeyError(f"Unknown P4 node: {node_alias}") - return TestMqlP4NodeNeighborhood( - node_alias=node_alias, - primary=self._p4_primary_connection_for_node(node_alias), - port_1=self._pnl0002_connection_for_node_port(node_alias, "port_1"), - port_3=self._pnl0002_connection_for_node_port(node_alias, "port_3"), - port_4=self._p4_port4_orifice_connection_for_node(node_alias), - ) - - def _p4_node_alias_for_orifice_port_2(self, orifice_alias: str) -> str: - for spec in CONNECTION_SPECS: - if spec["submodel"] != "DIRECT": - continue - if ( - spec["source_component"] == orifice_alias - and spec["source_port"] == "port_2" - and str(spec["target_component"]) in self.node4_assembly - ): - return str(spec["target_component"]) - if ( - spec["target_component"] == orifice_alias - and spec["target_port"] == "port_2" - and str(spec["source_component"]) in self.node4_assembly - ): - return str(spec["source_component"]) - raise KeyError(f"No P4 node attached to {orifice_alias}.port_2") - - def _pnl0003_spec_for_node_port(self, node_alias: str, port_name: str): - for spec in self.pnl0003_assembly.specs: - if spec.source_component == node_alias and spec.source_port == port_name: - return spec - if spec.target_component == node_alias and spec.target_port == port_name: - return spec - raise KeyError(f"No PNL0003 line attached to {node_alias}.{port_name}") - - def _pnl0003_line_for_node_port(self, node_alias: str, port_name: str): - spec = self._pnl0003_spec_for_node_port(node_alias, port_name) - return self.pnl0003_assembly.lines[spec.alias] - - def _pnl00r_line_for_node_port(self, node_alias: str, port_name: str): - for spec in self.pnl00r_assembly.specs: - if spec.source_component == node_alias and spec.source_port == port_name: - return self.pnl00r_assembly.lines[spec.alias] - if spec.target_component == node_alias and spec.target_port == port_name: - return self.pnl00r_assembly.lines[spec.alias] - raise KeyError(f"No PNL00R line attached to {node_alias}.{port_name}") - - def pneumatic_state_vector(self) -> list[float]: - return self.network.initial_state_vector() - - def mechanical_mass_closure(self): - from app.simulation.examples.test_mql.mechanical import TestMqlMechanicalMassClosure - - return TestMqlMechanicalMassClosure(self.mechanical_assembly) - - def mechanical_state_vector(self) -> list[float]: - return self.mechanical_mass_closure().initial_state_vector() - - def simulate_mechanical_masses( - self, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - closure = self.mechanical_mass_closure() - return integrate_ode( - rhs=lambda t, state: closure.rhs(state), - initial_state=closure.initial_state_vector(), - config=config or SolveIVPConfig(), - t_eval=t_eval, - ) - - def full_state_closure_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - resistance_boundary_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - resistance_boundary_temperature_k: float = 293.15, - use_pneumatic_96_reference_rhs: bool = False, - use_pneumatic_69_reference_rhs: bool = False, - ) -> TestMqlFullStateClosure: - return TestMqlFullStateClosure( - pneumatic_closure=self.pn3_p4_node_chamber_segment_closure_from_spec( - spec, - inlet_node_pressure_pa=inlet_node_pressure_pa, - resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, - inlet_node_temperature_k=inlet_node_temperature_k, - resistance_boundary_temperature_k=resistance_boundary_temperature_k, - use_inlet_line_reference_rhs=use_pneumatic_96_reference_rhs, - use_p4_port3_remote_primary_reference_rhs=( - use_pneumatic_69_reference_rhs - ), - ), - mechanical_closure=self.mechanical_mass_closure(), - pneumatic_assembly=self.pneumatic_assembly, - ) - - def simulate_full_state_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - resistance_boundary_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - resistance_boundary_temperature_k: float = 293.15, - use_pneumatic_96_reference_rhs: bool = False, - use_pneumatic_69_reference_rhs: bool = False, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - closure = self.full_state_closure_from_spec( - spec, - inlet_node_pressure_pa=inlet_node_pressure_pa, - resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, - inlet_node_temperature_k=inlet_node_temperature_k, - resistance_boundary_temperature_k=resistance_boundary_temperature_k, - use_pneumatic_96_reference_rhs=use_pneumatic_96_reference_rhs, - use_pneumatic_69_reference_rhs=use_pneumatic_69_reference_rhs, - ) - return integrate_ode( - rhs=lambda t, state: closure.rhs_at(t, state), - initial_state=closure.initial_state_vector(), - config=config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5), - t_eval=t_eval, - ) - - def simulate_full_state_series_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - resistance_boundary_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - resistance_boundary_temperature_k: float = 293.15, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - data_paths: tuple[str, ...] | list[str] | None = None, - use_pneumatic_96_reference_rhs: bool = False, - use_pneumatic_69_reference_rhs: bool = False, - ) -> TestMqlSimulationResult: - closure = self.full_state_closure_from_spec( - spec, - inlet_node_pressure_pa=inlet_node_pressure_pa, - resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, - inlet_node_temperature_k=inlet_node_temperature_k, - resistance_boundary_temperature_k=resistance_boundary_temperature_k, - use_pneumatic_96_reference_rhs=use_pneumatic_96_reference_rhs, - use_pneumatic_69_reference_rhs=use_pneumatic_69_reference_rhs, - ) - solution = integrate_ode( - rhs=lambda t, state: closure.rhs_at(t, state), - initial_state=closure.initial_state_vector(), - config=config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5), - t_eval=t_eval, - ) - times = [float(value) for value in solution.t] - state_rows = [list(row) for row in solution.y] - series = { - "time": times, - **closure.data_path_series( - times=times, - state_rows=state_rows, - data_paths=data_paths, - ), - } - return TestMqlSimulationResult(t=times, y=state_rows, series=series) - - @staticmethod - def pneumatic_branch_spec( - *, - name: str, - upstream_volume_alias: str, - orifice_alias: str, - downstream_volume_alias: str, - source: str = "manual", - ): - from app.simulation.examples.test_mql.closure import TestMqlPneumaticBranchSpec - - return TestMqlPneumaticBranchSpec( - name=name, - upstream_volume_alias=upstream_volume_alias, - orifice_alias=orifice_alias, - downstream_volume_alias=downstream_volume_alias, - source=source, - ) - - def discover_pneumatic_branch_topology(self): - from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticTopologyCandidate, - TestMqlPneumaticTopologyDiscovery, - ) - from app.simulation.examples.test_mql.pneumatic_topology import ( - discover_fixed_chamber_segments, - ) - from app.simulation.examples.test_mql.topology import load_test_mql_cir_topology - - topology = load_test_mql_cir_topology(self.archive_path) - chamber_segment_specs = discover_fixed_chamber_segments( - topology, - COMPONENT_SPECS, - CONNECTION_SPECS, - ) - - typed_aliases = set(self._pneumatic_components_by_alias()) - component_submodels = { - str(component["alias"]): str(component["submodel"]) - for component in COMPONENT_SPECS - } - blocked_candidates = [] - for spec in CONNECTION_SPECS: - source_component = str(spec["source_component"]) - target_component = str(spec["target_component"]) - source_is_typed = source_component in typed_aliases - target_is_typed = target_component in typed_aliases - if not (source_is_typed or target_is_typed): - continue - - line_submodel = str(spec["submodel"]) - endpoint_submodels = { - component_submodels.get(source_component, ""), - component_submodels.get(target_component, ""), - } - if "PNRP17" in endpoint_submodels: - category = "piston-boundary" - reason = "requires pneumatic piston and mechanical coupling interpretation" - elif "STEP0" in endpoint_submodels: - category = "control-boundary" - reason = "requires modulated-orifice control signal evaluation" - elif endpoint_submodels & {"PN3NODE2", "P4NODE2"}: - category = "node-line-boundary" - reason = "requires AMESim node/line equations before full-network closure" - elif source_is_typed and target_is_typed: - category = "typed-direct" - reason = "direct typed connection is not a complete simulated subnetwork" - elif line_submodel != "DIRECT": - category = "line-boundary" - reason = "requires AMESim line equations before full-network closure" - else: - category = "component-boundary" - reason = "requires AMESim boundary component interpretation" - - blocked_candidates.append( - TestMqlPneumaticTopologyCandidate( - connection_alias=str(spec["alias"]), - line_submodel=line_submodel, - source_component=source_component, - source_port=str(spec["source_port"]), - target_component=target_component, - target_port=str(spec["target_port"]), - source_is_typed_pneumatic=source_is_typed, - target_is_typed_pneumatic=target_is_typed, - category=category, - reason=reason, - ) - ) - - return TestMqlPneumaticTopologyDiscovery( - branch_specs=(), - chamber_segment_specs=chamber_segment_specs, - blocked_candidates=tuple(blocked_candidates), - ) - - def pneumatic_chamber_segment_closure_from_spec( - self, - spec, - *, - inlet_pressure_pa: float, - outlet_pressure_pa: float, - inlet_temperature_k: float = 293.15, - outlet_temperature_k: float = 293.15, - ): - """Build a reduced segment using absolute-pressure line boundaries. - - Boundary values apply at the PNL0001/orifice interfaces, not at the nodes. - """ - from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - ) - from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticBoundaryCondition, - TestMqlPneumaticChamberSegmentClosure, - TestMqlPneumaticChamberSegmentComponents, - ) - - volume = self.network.components[spec.volume_alias] - inlet_orifice = self.network.components[spec.inlet_orifice_alias] - outlet_orifice = self.network.components[spec.outlet_orifice_alias] - if not isinstance(volume, AmesimPneumaticVolume): - raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") - if not isinstance(inlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(outlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - return TestMqlPneumaticChamberSegmentClosure( - components=TestMqlPneumaticChamberSegmentComponents( - volume=volume, - inlet_orifice=inlet_orifice, - outlet_orifice=outlet_orifice, - spec=spec, - ), - inlet_boundary=TestMqlPneumaticBoundaryCondition( - pressure_pa=inlet_pressure_pa, - temperature_k=inlet_temperature_k, - ), - outlet_boundary=TestMqlPneumaticBoundaryCondition( - pressure_pa=outlet_pressure_pa, - temperature_k=outlet_temperature_k, - ), - ) - - def simulate_pneumatic_chamber_segment_from_spec( - self, - spec, - *, - inlet_pressure_pa: float, - outlet_pressure_pa: float, - inlet_temperature_k: float = 293.15, - outlet_temperature_k: float = 293.15, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - """Integrate a reduced segment with absolute-pressure line boundaries.""" - closure = self.pneumatic_chamber_segment_closure_from_spec( - spec, - inlet_pressure_pa=inlet_pressure_pa, - outlet_pressure_pa=outlet_pressure_pa, - inlet_temperature_k=inlet_temperature_k, - outlet_temperature_k=outlet_temperature_k, - ) - run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) - return integrate_ode( - rhs=lambda t, state: closure.rhs(state), - initial_state=closure.initial_state_vector(), - config=run_config, - t_eval=t_eval, - ) - - def pnl0001_chamber_segment_closure_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - outlet_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - outlet_temperature_k: float = 293.15, - ): - """Insert the topology-derived inlet PNL0001 into a chamber segment.""" - from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - ) - from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticBoundaryCondition, - TestMqlPnl0001ChamberSegmentClosure, - TestMqlPnl0001ChamberSegmentComponents, - ) - - inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] - volume = self.network.components[spec.volume_alias] - inlet_orifice = self.network.components[spec.inlet_orifice_alias] - outlet_orifice = self.network.components[spec.outlet_orifice_alias] - if not isinstance(volume, AmesimPneumaticVolume): - raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") - if not isinstance(inlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(outlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - return TestMqlPnl0001ChamberSegmentClosure( - components=TestMqlPnl0001ChamberSegmentComponents( - inlet_line=inlet_line, - volume=volume, - inlet_orifice=inlet_orifice, - outlet_orifice=outlet_orifice, - spec=spec, - ), - inlet_node=TestMqlPneumaticBoundaryCondition( - pressure_pa=inlet_node_pressure_pa, - temperature_k=inlet_node_temperature_k, - ), - outlet_boundary=TestMqlPneumaticBoundaryCondition( - pressure_pa=outlet_pressure_pa, - temperature_k=outlet_temperature_k, - ), - ) - - def simulate_pnl0001_chamber_segment_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - outlet_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - outlet_temperature_k: float = 293.15, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - closure = self.pnl0001_chamber_segment_closure_from_spec( - spec, - inlet_node_pressure_pa=inlet_node_pressure_pa, - outlet_pressure_pa=outlet_pressure_pa, - inlet_node_temperature_k=inlet_node_temperature_k, - outlet_temperature_k=outlet_temperature_k, - ) - run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) - return integrate_ode( - rhs=lambda t, state: closure.rhs(state), - initial_state=closure.initial_state_vector(), - config=run_config, - t_eval=t_eval, - ) - - def pnl0001_pair_chamber_segment_closure_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - outlet_node_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - outlet_node_temperature_k: float = 293.15, - ): - """Insert both topology-derived PNL0001 states into the segment.""" - from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - ) - from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticBoundaryCondition, - TestMqlPnl0001PairChamberSegmentClosure, - TestMqlPnl0001PairChamberSegmentComponents, - ) - - inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] - outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] - volume = self.network.components[spec.volume_alias] - inlet_orifice = self.network.components[spec.inlet_orifice_alias] - outlet_orifice = self.network.components[spec.outlet_orifice_alias] - if not isinstance(volume, AmesimPneumaticVolume): - raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") - if not isinstance(inlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(outlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - return TestMqlPnl0001PairChamberSegmentClosure( - components=TestMqlPnl0001PairChamberSegmentComponents( - inlet_line=inlet_line, - outlet_line=outlet_line, - volume=volume, - inlet_orifice=inlet_orifice, - outlet_orifice=outlet_orifice, - spec=spec, - ), - inlet_node=TestMqlPneumaticBoundaryCondition( - pressure_pa=inlet_node_pressure_pa, - temperature_k=inlet_node_temperature_k, - ), - outlet_node=TestMqlPneumaticBoundaryCondition( - pressure_pa=outlet_node_pressure_pa, - temperature_k=outlet_node_temperature_k, - ), - ) - - def simulate_pnl0001_pair_chamber_segment_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - outlet_node_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - outlet_node_temperature_k: float = 293.15, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - closure = self.pnl0001_pair_chamber_segment_closure_from_spec( - spec, - inlet_node_pressure_pa=inlet_node_pressure_pa, - outlet_node_pressure_pa=outlet_node_pressure_pa, - inlet_node_temperature_k=inlet_node_temperature_k, - outlet_node_temperature_k=outlet_node_temperature_k, - ) - run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) - return integrate_ode( - rhs=lambda t, state: closure.rhs(state), - initial_state=closure.initial_state_vector(), - config=run_config, - t_eval=t_eval, - ) - - def pn3_node_chamber_segment_closure_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - resistance_boundary_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - resistance_boundary_temperature_k: float = 293.15, - p4_boundary_pressure_pa: float = 15.3e6, - p4_boundary_temperature_k: float = 293.15, - ): - """Close a PNL0001 chamber segment with the real outlet PN3 node. - - The PN3 primary pressure/temperature is taken from the attached PNL0003 - port-1 compliance. The PNL0003 port-2 side is connected through the - real PNVO001 orifice to a prescribed P4 pressure boundary. - """ - from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - ) - from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticBoundaryCondition, - TestMqlPn3NodeChamberSegmentClosure, - TestMqlPn3NodeChamberSegmentComponents, - ) - - inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] - outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] - node_line_spec = self._pnl0003_spec_for_node_port(spec.outlet_node_alias, "port_2") - node_line = self.pnl0003_assembly.lines[node_line_spec.alias] - node_orifice_alias = ( - node_line_spec.target_component - if node_line_spec.source_component == spec.outlet_node_alias - else node_line_spec.source_component - ) - node_orifice = self.pneumatic_assembly.variable_orifices[node_orifice_alias] - node_resistance = self._pnl00r_line_for_node_port( - spec.outlet_node_alias, "port_1" - ) - node = self.node3_assembly[spec.outlet_node_alias] - volume = self.network.components[spec.volume_alias] - inlet_orifice = self.network.components[spec.inlet_orifice_alias] - outlet_orifice = self.network.components[spec.outlet_orifice_alias] - if not isinstance(volume, AmesimPneumaticVolume): - raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") - if not isinstance(inlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(outlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - return TestMqlPn3NodeChamberSegmentClosure( - components=TestMqlPn3NodeChamberSegmentComponents( - inlet_line=inlet_line, - outlet_line=outlet_line, - node_line=node_line, - node_resistance=node_resistance, - node_orifice=node_orifice, - node=node, - volume=volume, - inlet_orifice=inlet_orifice, - outlet_orifice=outlet_orifice, - spec=spec, - ), - inlet_node=TestMqlPneumaticBoundaryCondition( - pressure_pa=inlet_node_pressure_pa, - temperature_k=inlet_node_temperature_k, - ), - resistance_boundary=TestMqlPneumaticBoundaryCondition( - pressure_pa=resistance_boundary_pressure_pa, - temperature_k=resistance_boundary_temperature_k, - ), - p4_boundary=TestMqlPneumaticBoundaryCondition( - pressure_pa=p4_boundary_pressure_pa, - temperature_k=p4_boundary_temperature_k, - ), - ) - - def simulate_pn3_node_chamber_segment_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - resistance_boundary_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - resistance_boundary_temperature_k: float = 293.15, - p4_boundary_pressure_pa: float = 15.3e6, - p4_boundary_temperature_k: float = 293.15, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - closure = self.pn3_node_chamber_segment_closure_from_spec( - spec, - inlet_node_pressure_pa=inlet_node_pressure_pa, - resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, - inlet_node_temperature_k=inlet_node_temperature_k, - resistance_boundary_temperature_k=resistance_boundary_temperature_k, - p4_boundary_pressure_pa=p4_boundary_pressure_pa, - p4_boundary_temperature_k=p4_boundary_temperature_k, - ) - run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) - return integrate_ode( - rhs=lambda t, state: closure.rhs(state), - initial_state=closure.initial_state_vector(), - config=run_config, - t_eval=t_eval, - ) - - def pn3_p4_node_chamber_segment_closure_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - resistance_boundary_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - resistance_boundary_temperature_k: float = 293.15, - use_inlet_line_reference_rhs: bool = False, - use_p4_port3_remote_primary_reference_rhs: bool = False, - ): - """Close a PN3 chamber segment through the adjacent real P4 node. - - The P4 primary pressure/temperature is supplied by the PNL0001 line on - P4 port 2. Ports 1 and 3 use the adjacent PNL0002 center compliances. - The next P4 nodes contribute their primary PNL0001/chamber states, and - PNVO upstream endpoints are closed through their adjacent PN3/PNL0003 - subnets. - """ - from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - ) - from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticBoundaryCondition, - TestMqlPn3P4NodeChamberSegmentClosure, - TestMqlPn3P4NodeChamberSegmentComponents, - ) - - def pnl0003_line_and_node_for_orifice(orifice_alias: str): - for line_spec in self.pnl0003_assembly.specs: - if ( - line_spec.target_component == orifice_alias - and line_spec.target_port == "port_3" - and line_spec.source_component in self.node3_assembly - ): - return ( - self.pnl0003_assembly.lines[line_spec.alias], - self.node3_assembly[line_spec.source_component], - ) - if ( - line_spec.source_component == orifice_alias - and line_spec.source_port == "port_3" - and line_spec.target_component in self.node3_assembly - ): - return ( - self.pnl0003_assembly.lines[line_spec.alias], - self.node3_assembly[line_spec.target_component], - ) - raise KeyError(f"No PN3/PNL0003 line attached to {orifice_alias}.port_3") - - def other_node3_for_pnl00r(line_alias: str, local_node_alias: str): - for line_spec in self.pnl00r_assembly.specs: - if line_spec.alias != line_alias: - continue - if line_spec.source_component == local_node_alias: - return self.node3_assembly[line_spec.target_component] - if line_spec.target_component == local_node_alias: - return self.node3_assembly[line_spec.source_component] - raise KeyError(f"No remote PN3 node found for {line_alias}") - - def chamber_segment_for_inlet_node(node_alias: str): - chamber_specs = ( - self.discover_pneumatic_branch_topology().chamber_segment_specs - ) - for chamber_spec in chamber_specs: - if chamber_spec.inlet_node_alias == node_alias: - return chamber_spec - raise KeyError(f"No fixed chamber segment starts at {node_alias}") - - def chamber_segment_for_outlet_node(node_alias: str): - chamber_specs = ( - self.discover_pneumatic_branch_topology().chamber_segment_specs - ) - for chamber_spec in chamber_specs: - if chamber_spec.outlet_node_alias == node_alias: - return chamber_spec - raise KeyError(f"No fixed chamber segment ends at {node_alias}") - - inlet_line = self.pnl0001_assembly.lines[spec.inlet_line_alias] - outlet_line = self.pnl0001_assembly.lines[spec.outlet_line_alias] - node_line_spec = self._pnl0003_spec_for_node_port( - spec.outlet_node_alias, - "port_2", - ) - node_line = self.pnl0003_assembly.lines[node_line_spec.alias] - node_orifice_alias = ( - node_line_spec.target_component - if node_line_spec.source_component == spec.outlet_node_alias - else node_line_spec.source_component - ) - p4_node_alias = self._p4_node_alias_for_orifice_port_2(node_orifice_alias) - p4_neighborhood = self.p4_node_neighborhood(p4_node_alias) - if p4_neighborhood.port_4.orifice_alias != node_orifice_alias: - raise ValueError( - f"P4 neighborhood for {p4_node_alias} does not match " - f"{node_orifice_alias}" - ) - node_orifice = self.pneumatic_assembly.variable_orifices[ - p4_neighborhood.port_4.orifice_alias - ] - node_resistance = self._pnl00r_line_for_node_port( - spec.outlet_node_alias, - "port_1", - ) - node = self.node3_assembly[spec.outlet_node_alias] - p4_node = self.node4_assembly[p4_neighborhood.node_alias] - p4_primary_line = self.pnl0001_assembly.lines[ - p4_neighborhood.primary.line_alias - ] - p4_primary_chamber_alias = p4_neighborhood.primary.chamber_alias - p4_primary_chamber = self.network.components[p4_primary_chamber_alias] - p4_port1_line = self.pnl0002_assembly.lines[ - p4_neighborhood.port_1.line_alias - ] - p4_port3_line = self.pnl0002_assembly.lines[ - p4_neighborhood.port_3.line_alias - ] - p4_port1_remote_neighborhood = self.p4_node_neighborhood( - p4_neighborhood.port_1.remote_node_alias - ) - p4_port1_remote_node = self.node4_assembly[ - p4_port1_remote_neighborhood.node_alias - ] - p4_port1_remote_primary_line = self.pnl0001_assembly.lines[ - p4_port1_remote_neighborhood.primary.line_alias - ] - p4_port1_remote_primary_chamber_alias = ( - p4_port1_remote_neighborhood.primary.chamber_alias - ) - p4_port1_remote_primary_chamber = self.network.components[ - p4_port1_remote_primary_chamber_alias - ] - p4_port1_remote_port1_line = self.pnl0002_assembly.lines[ - p4_port1_remote_neighborhood.port_1.line_alias - ] - p4_port1_far_node_alias = p4_port1_remote_neighborhood.port_1.remote_node_alias - p4_port1_far_node = self.node4_assembly[p4_port1_far_node_alias] - p4_port1_far_primary = self._p4_primary_connection_for_node( - p4_port1_far_node_alias - ) - p4_port1_far_primary_line = self.pnl0001_assembly.lines[ - p4_port1_far_primary.line_alias - ] - p4_port1_far_primary_chamber_alias = p4_port1_far_primary.chamber_alias - p4_port1_far_primary_chamber = self.network.components[ - p4_port1_far_primary_chamber_alias - ] - p4_port1_far_port1 = self._pnl0002_connection_for_node_port( - p4_port1_far_node_alias, - "port_1", - ) - p4_port1_far_port1_line = self.pnl0002_assembly.lines[ - p4_port1_far_port1.line_alias - ] - p4_port1_next_node_alias = p4_port1_far_port1.remote_node_alias - p4_port1_next_node = self.node4_assembly[p4_port1_next_node_alias] - p4_port1_next_primary = self._p4_primary_connection_for_node( - p4_port1_next_node_alias - ) - p4_port1_next_primary_line = self.pnl0001_assembly.lines[ - p4_port1_next_primary.line_alias - ] - p4_port1_next_primary_chamber_alias = p4_port1_next_primary.chamber_alias - p4_port1_next_primary_chamber = self.network.components[ - p4_port1_next_primary_chamber_alias - ] - p4_port1_next_port1 = self._pnl0002_connection_for_node_port( - p4_port1_next_node_alias, - "port_1", - ) - p4_port1_next_port1_line = self.pnl0002_assembly.lines[ - p4_port1_next_port1.line_alias - ] - p4_port1_next_orifice_output_spec = self._pnl0001_spec_for_node_port( - p4_port1_next_node_alias, - "port_4", - ) - p4_port1_next_orifice_output_line = self.pnl0001_assembly.lines[ - p4_port1_next_orifice_output_spec.alias - ] - p4_bridge_node_alias = p4_port1_next_port1.remote_node_alias - p4_bridge_node = self.node4_assembly[p4_bridge_node_alias] - p4_bridge_primary = self._p4_primary_connection_for_node( - p4_bridge_node_alias - ) - p4_bridge_primary_line = self.pnl0001_assembly.lines[ - p4_bridge_primary.line_alias - ] - p4_bridge_primary_chamber_alias = p4_bridge_primary.chamber_alias - p4_bridge_primary_chamber = self.network.components[ - p4_bridge_primary_chamber_alias - ] - p4_port1_remote_orifice = self.pneumatic_assembly.variable_orifices[ - p4_port1_remote_neighborhood.port_4.orifice_alias - ] - ( - p4_port1_remote_orifice_line, - p4_port1_remote_orifice_node, - ) = pnl0003_line_and_node_for_orifice( - p4_port1_remote_neighborhood.port_4.orifice_alias - ) - p4_port3_remote_neighborhood = self.p4_node_neighborhood( - p4_neighborhood.port_3.remote_node_alias - ) - p4_port3_remote_node = self.node4_assembly[ - p4_port3_remote_neighborhood.node_alias - ] - p4_port3_remote_primary_line = self.pnl0001_assembly.lines[ - p4_port3_remote_neighborhood.primary.line_alias - ] - p4_port3_remote_primary_chamber_alias = ( - p4_port3_remote_neighborhood.primary.chamber_alias - ) - p4_port3_remote_primary_chamber = self.network.components[ - p4_port3_remote_primary_chamber_alias - ] - p4_port3_remote_port3_line = self.pnl0002_assembly.lines[ - p4_port3_remote_neighborhood.port_3.line_alias - ] - p4_port3_far_node_alias = p4_port3_remote_neighborhood.port_3.remote_node_alias - p4_port3_far_node = self.node4_assembly[p4_port3_far_node_alias] - p4_port3_far_primary = self._p4_primary_connection_for_node( - p4_port3_far_node_alias - ) - p4_port3_far_primary_line = self.pnl0001_assembly.lines[ - p4_port3_far_primary.line_alias - ] - p4_port3_far_primary_chamber_alias = p4_port3_far_primary.chamber_alias - p4_port3_far_primary_chamber = self.network.components[ - p4_port3_far_primary_chamber_alias - ] - p4_port3_far_port3 = self._pnl0002_connection_for_node_port( - p4_port3_far_node_alias, - "port_3", - ) - p4_port3_far_port3_line = self.pnl0002_assembly.lines[ - p4_port3_far_port3.line_alias - ] - p4_port3_next_node_alias = p4_port3_far_port3.remote_node_alias - p4_port3_next_node = self.node4_assembly[p4_port3_next_node_alias] - p4_port3_next_primary = self._p4_primary_connection_for_node( - p4_port3_next_node_alias - ) - p4_port3_next_primary_line = self.pnl0001_assembly.lines[ - p4_port3_next_primary.line_alias - ] - p4_port3_next_primary_chamber_alias = p4_port3_next_primary.chamber_alias - p4_port3_next_primary_chamber = self.network.components[ - p4_port3_next_primary_chamber_alias - ] - p4_port3_next_port3 = self._pnl0002_connection_for_node_port( - p4_port3_next_node_alias, - "port_3", - ) - if p4_port3_next_port3.remote_node_alias != p4_bridge_node_alias: - raise ValueError( - f"{p4_port3_next_port3.line_alias} does not connect back to " - f"{p4_bridge_node_alias}" - ) - p4_port3_next_port3_line = self.pnl0002_assembly.lines[ - p4_port3_next_port3.line_alias - ] - p4_port3_next_orifice_output_spec = self._pnl0001_spec_for_node_port( - p4_port3_next_node_alias, - "port_4", - ) - p4_port3_next_orifice_alias = ( - p4_port3_next_orifice_output_spec.source_component - if p4_port3_next_orifice_output_spec.target_component - == p4_port3_next_node_alias - else p4_port3_next_orifice_output_spec.target_component - ) - p4_port3_next_orifice = self.pneumatic_assembly.variable_orifices[ - p4_port3_next_orifice_alias - ] - ( - p4_port3_next_orifice_line, - p4_port3_next_orifice_node, - ) = pnl0003_line_and_node_for_orifice(p4_port3_next_orifice_alias) - p4_port3_next_orifice_output_line = self.pnl0001_assembly.lines[ - p4_port3_next_orifice_output_spec.alias - ] - p4_port3_next_chamber_spec = chamber_segment_for_inlet_node( - p4_port3_next_orifice_node.alias - ) - p4_port3_next_chamber_inlet_line = self.pnl0001_assembly.lines[ - p4_port3_next_chamber_spec.inlet_line_alias - ] - p4_port3_next_chamber = self.network.components[ - p4_port3_next_chamber_spec.volume_alias - ] - p4_port3_next_chamber_outlet_line = self.pnl0001_assembly.lines[ - p4_port3_next_chamber_spec.outlet_line_alias - ] - p4_port3_next_chamber_inlet_orifice = self.pneumatic_assembly.fixed_orifices[ - p4_port3_next_chamber_spec.inlet_orifice_alias - ] - p4_port3_next_chamber_outlet_orifice = self.pneumatic_assembly.fixed_orifices[ - p4_port3_next_chamber_spec.outlet_orifice_alias - ] - p4_port3_next_orifice_resistance = self._pnl00r_line_for_node_port( - p4_port3_next_orifice_node.alias, - "port_3", - ) - p4_port3_next_resistance_node = other_node3_for_pnl00r( - p4_port3_next_orifice_resistance.name, - p4_port3_next_orifice_node.alias, - ) - p4_port3_next_resistance_node_line_spec = self._pnl0003_spec_for_node_port( - p4_port3_next_resistance_node.alias, - "port_2", - ) - p4_port3_next_resistance_node_line = self.pnl0003_assembly.lines[ - p4_port3_next_resistance_node_line_spec.alias - ] - p4_port3_next_resistance_orifice_alias = ( - p4_port3_next_resistance_node_line_spec.target_component - if p4_port3_next_resistance_node_line_spec.source_component - == p4_port3_next_resistance_node.alias - else p4_port3_next_resistance_node_line_spec.source_component - ) - p4_port3_next_resistance_orifice = self.pneumatic_assembly.variable_orifices[ - p4_port3_next_resistance_orifice_alias - ] - p4_port3_next_resistance_output_spec = self._pnl0001_spec_for_node_port( - p4_bridge_node_alias, - "port_4", - ) - if ( - p4_port3_next_resistance_output_spec.source_component - != p4_port3_next_resistance_orifice_alias - ): - raise ValueError( - f"{p4_port3_next_resistance_output_spec.alias} is not fed by " - f"{p4_port3_next_resistance_orifice_alias}" - ) - p4_port3_next_resistance_output_line = self.pnl0001_assembly.lines[ - p4_port3_next_resistance_output_spec.alias - ] - p4_port3_next_resistance_chamber_spec = chamber_segment_for_outlet_node( - p4_port3_next_resistance_node.alias - ) - p4_port3_next_resistance_chamber_inlet_line = self.pnl0001_assembly.lines[ - p4_port3_next_resistance_chamber_spec.inlet_line_alias - ] - p4_port3_next_resistance_chamber = self.network.components[ - p4_port3_next_resistance_chamber_spec.volume_alias - ] - p4_port3_next_resistance_chamber_outlet_line = self.pnl0001_assembly.lines[ - p4_port3_next_resistance_chamber_spec.outlet_line_alias - ] - p4_port3_next_resistance_chamber_inlet_orifice = ( - self.pneumatic_assembly.fixed_orifices[ - p4_port3_next_resistance_chamber_spec.inlet_orifice_alias - ] - ) - p4_port3_next_resistance_chamber_outlet_orifice = ( - self.pneumatic_assembly.fixed_orifices[ - p4_port3_next_resistance_chamber_spec.outlet_orifice_alias - ] - ) - p4_port1_next_orifice_node = self.node3_assembly[ - p4_port3_next_resistance_chamber_spec.inlet_node_alias - ] - p4_port1_next_orifice_line_spec = self._pnl0003_spec_for_node_port( - p4_port1_next_orifice_node.alias, - "port_2", - ) - p4_port1_next_orifice_line = self.pnl0003_assembly.lines[ - p4_port1_next_orifice_line_spec.alias - ] - p4_port1_next_orifice_alias = ( - p4_port1_next_orifice_line_spec.target_component - if p4_port1_next_orifice_line_spec.source_component - == p4_port1_next_orifice_node.alias - else p4_port1_next_orifice_line_spec.source_component - ) - p4_port1_next_orifice = self.pneumatic_assembly.variable_orifices[ - p4_port1_next_orifice_alias - ] - if ( - p4_port1_next_orifice_output_spec.source_component != p4_port1_next_node_alias - or p4_port1_next_orifice_output_spec.target_component - != p4_port1_next_orifice_alias - ): - raise ValueError( - f"{p4_port1_next_orifice_output_spec.alias} does not connect " - f"{p4_port1_next_node_alias}.port_4 to {p4_port1_next_orifice_alias}" - ) - p4_port1_next_orifice_resistance = self._pnl00r_line_for_node_port( - p4_port1_next_orifice_node.alias, - "port_3", - ) - p4_port1_next_orifice_resistance_node = other_node3_for_pnl00r( - p4_port1_next_orifice_resistance.name, - p4_port1_next_orifice_node.alias, - ) - p4_port1_next_orifice_resistance_line_spec = self._pnl0003_spec_for_node_port( - p4_port1_next_orifice_resistance_node.alias, - "port_2", - ) - p4_port1_next_orifice_resistance_line = self.pnl0003_assembly.lines[ - p4_port1_next_orifice_resistance_line_spec.alias - ] - p4_port1_next_orifice_resistance_orifice_alias = ( - p4_port1_next_orifice_resistance_line_spec.target_component - if p4_port1_next_orifice_resistance_line_spec.source_component - == p4_port1_next_orifice_resistance_node.alias - else p4_port1_next_orifice_resistance_line_spec.source_component - ) - p4_port1_next_orifice_resistance_orifice = ( - self.pneumatic_assembly.variable_orifices[ - p4_port1_next_orifice_resistance_orifice_alias - ] - ) - p4_port1_far_orifice_direct = self.p4_node_neighborhood( - p4_port1_far_node_alias - ).port_4 - if ( - p4_port1_far_orifice_direct.orifice_alias - != p4_port1_next_orifice_resistance_orifice_alias - ): - raise ValueError( - f"{p4_port1_far_node_alias}.port_4 does not use " - f"{p4_port1_next_orifice_resistance_orifice_alias}" - ) - p4_port1_next_orifice_resistance_chamber_spec = ( - chamber_segment_for_outlet_node( - p4_port1_next_orifice_resistance_node.alias - ) - ) - p4_port1_next_orifice_resistance_chamber_inlet_line = ( - self.pnl0001_assembly.lines[ - p4_port1_next_orifice_resistance_chamber_spec.inlet_line_alias - ] - ) - p4_port1_next_orifice_resistance_chamber = self.network.components[ - p4_port1_next_orifice_resistance_chamber_spec.volume_alias - ] - p4_port1_next_orifice_resistance_chamber_outlet_line = ( - self.pnl0001_assembly.lines[ - p4_port1_next_orifice_resistance_chamber_spec.outlet_line_alias - ] - ) - p4_port1_next_orifice_resistance_chamber_inlet_orifice = ( - self.pneumatic_assembly.fixed_orifices[ - p4_port1_next_orifice_resistance_chamber_spec.inlet_orifice_alias - ] - ) - p4_port1_next_orifice_resistance_chamber_outlet_orifice = ( - self.pneumatic_assembly.fixed_orifices[ - p4_port1_next_orifice_resistance_chamber_spec.outlet_orifice_alias - ] - ) - p4_port3_remote_orifice = self.pneumatic_assembly.variable_orifices[ - p4_port3_remote_neighborhood.port_4.orifice_alias - ] - ( - p4_port3_remote_orifice_line, - p4_port3_remote_orifice_node, - ) = pnl0003_line_and_node_for_orifice( - p4_port3_remote_neighborhood.port_4.orifice_alias - ) - p4_port3_remote_orifice_resistance = self._pnl00r_line_for_node_port( - p4_port3_remote_orifice_node.alias, - "port_3", - ) - p4_port3_remote_resistance_node = other_node3_for_pnl00r( - p4_port3_remote_orifice_resistance.name, - p4_port3_remote_orifice_node.alias, - ) - p4_port3_remote_resistance_node_line_spec = self._pnl0003_spec_for_node_port( - p4_port3_remote_resistance_node.alias, - "port_2", - ) - p4_port3_remote_resistance_node_line = self.pnl0003_assembly.lines[ - p4_port3_remote_resistance_node_line_spec.alias - ] - p4_port3_remote_resistance_orifice_alias = ( - p4_port3_remote_resistance_node_line_spec.target_component - if p4_port3_remote_resistance_node_line_spec.source_component - == p4_port3_remote_resistance_node.alias - else p4_port3_remote_resistance_node_line_spec.source_component - ) - p4_port3_remote_resistance_orifice = self.pneumatic_assembly.variable_orifices[ - p4_port3_remote_resistance_orifice_alias - ] - p4_port3_remote_resistance_output_spec = self._pnl0001_spec_for_node_port( - p4_port3_far_node_alias, - "port_4", - ) - if ( - p4_port3_remote_resistance_output_spec.source_component - != p4_port3_remote_resistance_orifice_alias - ): - raise ValueError( - f"{p4_port3_remote_resistance_output_spec.alias} is not fed by " - f"{p4_port3_remote_resistance_orifice_alias}" - ) - p4_port3_remote_resistance_output_line = self.pnl0001_assembly.lines[ - p4_port3_remote_resistance_output_spec.alias - ] - volume = self.network.components[spec.volume_alias] - inlet_orifice = self.network.components[spec.inlet_orifice_alias] - outlet_orifice = self.network.components[spec.outlet_orifice_alias] - if not isinstance(volume, AmesimPneumaticVolume): - raise TypeError(f"{spec.volume_alias} is not an AMESim pneumatic volume") - if not isinstance(p4_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port1_remote_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port1_remote_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port3_remote_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port3_remote_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port1_far_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port1_far_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port3_far_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port3_far_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port1_next_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port1_next_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port3_next_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port3_next_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port3_next_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{p4_port3_next_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(p4_port3_next_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port3_next_chamber_spec.volume_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(p4_port3_next_chamber_inlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{p4_port3_next_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(p4_port3_next_chamber_outlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{p4_port3_next_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(p4_port3_next_resistance_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{p4_port3_next_resistance_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(p4_port3_next_resistance_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_port3_next_resistance_chamber_spec.volume_alias} is not an AMESim pneumatic volume" - ) - if not isinstance( - p4_port3_next_resistance_chamber_inlet_orifice, - AmesimPneumaticOrifice, - ): - raise TypeError( - f"{p4_port3_next_resistance_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance( - p4_port3_next_resistance_chamber_outlet_orifice, - AmesimPneumaticOrifice, - ): - raise TypeError( - f"{p4_port3_next_resistance_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(p4_port1_next_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{p4_port1_next_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance( - p4_port1_next_orifice_resistance_orifice, - AmesimPneumaticOrifice, - ): - raise TypeError( - f"{p4_port1_next_orifice_resistance_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance( - p4_port1_next_orifice_resistance_chamber, - AmesimPneumaticVolume, - ): - raise TypeError( - f"{p4_port1_next_orifice_resistance_chamber_spec.volume_alias} is not an AMESim pneumatic volume" - ) - if not isinstance( - p4_port1_next_orifice_resistance_chamber_inlet_orifice, - AmesimPneumaticOrifice, - ): - raise TypeError( - f"{p4_port1_next_orifice_resistance_chamber_spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance( - p4_port1_next_orifice_resistance_chamber_outlet_orifice, - AmesimPneumaticOrifice, - ): - raise TypeError( - f"{p4_port1_next_orifice_resistance_chamber_spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(p4_bridge_primary_chamber, AmesimPneumaticVolume): - raise TypeError( - f"{p4_bridge_primary_chamber_alias} is not an AMESim pneumatic volume" - ) - if not isinstance(inlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.inlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(outlet_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{spec.outlet_orifice_alias} is not an AMESim pneumatic orifice" - ) - if not isinstance(p4_port3_remote_resistance_orifice, AmesimPneumaticOrifice): - raise TypeError( - f"{p4_port3_remote_resistance_orifice_alias} is not an AMESim pneumatic orifice" - ) - return TestMqlPn3P4NodeChamberSegmentClosure( - components=TestMqlPn3P4NodeChamberSegmentComponents( - inlet_line=inlet_line, - outlet_line=outlet_line, - node_line=node_line, - node_resistance=node_resistance, - node_orifice=node_orifice, - node=node, - p4_node=p4_node, - p4_primary_line=p4_primary_line, - p4_primary_chamber=p4_primary_chamber, - p4_port1_line=p4_port1_line, - p4_port1_remote_node=p4_port1_remote_node, - p4_port1_remote_primary_line=p4_port1_remote_primary_line, - p4_port1_remote_primary_chamber=p4_port1_remote_primary_chamber, - p4_port1_remote_port1_line=p4_port1_remote_port1_line, - p4_port1_far_node=p4_port1_far_node, - p4_port1_far_primary_line=p4_port1_far_primary_line, - p4_port1_far_primary_chamber=p4_port1_far_primary_chamber, - p4_port1_far_port1_line=p4_port1_far_port1_line, - p4_port1_next_node=p4_port1_next_node, - p4_port1_next_primary_line=p4_port1_next_primary_line, - p4_port1_next_primary_chamber=p4_port1_next_primary_chamber, - p4_port1_next_port1_line=p4_port1_next_port1_line, - p4_port1_next_orifice=p4_port1_next_orifice, - p4_port1_next_orifice_line=p4_port1_next_orifice_line, - p4_port1_next_orifice_node=p4_port1_next_orifice_node, - p4_port1_next_orifice_output_line=p4_port1_next_orifice_output_line, - p4_port1_next_orifice_resistance=p4_port1_next_orifice_resistance, - p4_port1_next_orifice_resistance_node=( - p4_port1_next_orifice_resistance_node - ), - p4_port1_next_orifice_resistance_line=( - p4_port1_next_orifice_resistance_line - ), - p4_port1_next_orifice_resistance_orifice=( - p4_port1_next_orifice_resistance_orifice - ), - p4_port1_next_orifice_resistance_chamber_inlet_line=( - p4_port1_next_orifice_resistance_chamber_inlet_line - ), - p4_port1_next_orifice_resistance_chamber=( - p4_port1_next_orifice_resistance_chamber - ), - p4_port1_next_orifice_resistance_chamber_outlet_line=( - p4_port1_next_orifice_resistance_chamber_outlet_line - ), - p4_port1_next_orifice_resistance_chamber_inlet_orifice=( - p4_port1_next_orifice_resistance_chamber_inlet_orifice - ), - p4_port1_next_orifice_resistance_chamber_outlet_orifice=( - p4_port1_next_orifice_resistance_chamber_outlet_orifice - ), - p4_port1_next_orifice_resistance_chamber_spec=( - p4_port1_next_orifice_resistance_chamber_spec - ), - p4_bridge_node=p4_bridge_node, - p4_bridge_primary_line=p4_bridge_primary_line, - p4_bridge_primary_chamber=p4_bridge_primary_chamber, - p4_port1_remote_orifice=p4_port1_remote_orifice, - p4_port1_remote_orifice_line=p4_port1_remote_orifice_line, - p4_port1_remote_orifice_node=p4_port1_remote_orifice_node, - p4_port3_line=p4_port3_line, - p4_port3_remote_node=p4_port3_remote_node, - p4_port3_remote_primary_line=p4_port3_remote_primary_line, - p4_port3_remote_primary_chamber=p4_port3_remote_primary_chamber, - p4_port3_remote_port3_line=p4_port3_remote_port3_line, - p4_port3_far_node=p4_port3_far_node, - p4_port3_far_primary_line=p4_port3_far_primary_line, - p4_port3_far_primary_chamber=p4_port3_far_primary_chamber, - p4_port3_far_port3_line=p4_port3_far_port3_line, - p4_port3_next_node=p4_port3_next_node, - p4_port3_next_primary_line=p4_port3_next_primary_line, - p4_port3_next_primary_chamber=p4_port3_next_primary_chamber, - p4_port3_next_port3_line=p4_port3_next_port3_line, - p4_port3_next_orifice=p4_port3_next_orifice, - p4_port3_next_orifice_line=p4_port3_next_orifice_line, - p4_port3_next_orifice_node=p4_port3_next_orifice_node, - p4_port3_next_orifice_output_line=p4_port3_next_orifice_output_line, - p4_port3_next_chamber_inlet_line=p4_port3_next_chamber_inlet_line, - p4_port3_next_chamber=p4_port3_next_chamber, - p4_port3_next_chamber_outlet_line=p4_port3_next_chamber_outlet_line, - p4_port3_next_chamber_inlet_orifice=p4_port3_next_chamber_inlet_orifice, - p4_port3_next_chamber_outlet_orifice=p4_port3_next_chamber_outlet_orifice, - p4_port3_next_chamber_spec=p4_port3_next_chamber_spec, - p4_port3_next_orifice_resistance=p4_port3_next_orifice_resistance, - p4_port3_next_resistance_node=p4_port3_next_resistance_node, - p4_port3_next_resistance_node_line=p4_port3_next_resistance_node_line, - p4_port3_next_resistance_orifice=p4_port3_next_resistance_orifice, - p4_port3_next_resistance_output_line=p4_port3_next_resistance_output_line, - p4_port3_next_resistance_chamber_inlet_line=( - p4_port3_next_resistance_chamber_inlet_line - ), - p4_port3_next_resistance_chamber=p4_port3_next_resistance_chamber, - p4_port3_next_resistance_chamber_outlet_line=( - p4_port3_next_resistance_chamber_outlet_line - ), - p4_port3_next_resistance_chamber_inlet_orifice=( - p4_port3_next_resistance_chamber_inlet_orifice - ), - p4_port3_next_resistance_chamber_outlet_orifice=( - p4_port3_next_resistance_chamber_outlet_orifice - ), - p4_port3_next_resistance_chamber_spec=( - p4_port3_next_resistance_chamber_spec - ), - p4_port3_remote_orifice=p4_port3_remote_orifice, - p4_port3_remote_orifice_line=p4_port3_remote_orifice_line, - p4_port3_remote_orifice_node=p4_port3_remote_orifice_node, - p4_port3_remote_orifice_resistance=p4_port3_remote_orifice_resistance, - p4_port3_remote_resistance_node=p4_port3_remote_resistance_node, - p4_port3_remote_resistance_node_line=( - p4_port3_remote_resistance_node_line - ), - p4_port3_remote_resistance_orifice=( - p4_port3_remote_resistance_orifice - ), - p4_port3_remote_resistance_output_line=( - p4_port3_remote_resistance_output_line - ), - volume=volume, - inlet_orifice=inlet_orifice, - outlet_orifice=outlet_orifice, - spec=spec, - ), - inlet_node=TestMqlPneumaticBoundaryCondition( - pressure_pa=inlet_node_pressure_pa, - temperature_k=inlet_node_temperature_k, - ), - resistance_boundary=TestMqlPneumaticBoundaryCondition( - pressure_pa=resistance_boundary_pressure_pa, - temperature_k=resistance_boundary_temperature_k, - ), - use_inlet_line_reference_rhs=use_inlet_line_reference_rhs, - use_p4_port3_remote_primary_reference_rhs=( - use_p4_port3_remote_primary_reference_rhs - ), - ) - - def simulate_pn3_p4_node_chamber_segment_from_spec( - self, - spec, - *, - inlet_node_pressure_pa: float, - resistance_boundary_pressure_pa: float, - inlet_node_temperature_k: float = 293.15, - resistance_boundary_temperature_k: float = 293.15, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - closure = self.pn3_p4_node_chamber_segment_closure_from_spec( - spec, - inlet_node_pressure_pa=inlet_node_pressure_pa, - resistance_boundary_pressure_pa=resistance_boundary_pressure_pa, - inlet_node_temperature_k=inlet_node_temperature_k, - resistance_boundary_temperature_k=resistance_boundary_temperature_k, - ) - run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) - return integrate_ode( - rhs=lambda t, state: closure.rhs(state), - initial_state=closure.initial_state_vector(), - config=run_config, - t_eval=t_eval, - ) - - def pneumatic_branch_closure_from_spec(self, spec): - return self.pneumatic_branch_closure( - name=spec.name, - upstream_volume_alias=spec.upstream_volume_alias, - orifice_alias=spec.orifice_alias, - downstream_volume_alias=spec.downstream_volume_alias, - spec=spec, - ) - - def pneumatic_branch_closure( - self, - *, - name: str, - upstream_volume_alias: str, - orifice_alias: str, - downstream_volume_alias: str, - spec=None, - ): - from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - ) - from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticBranchComponents, - TestMqlPneumaticBranchSpec, - TestMqlPneumaticClosure, - ) - - upstream_volume = self.network.components[upstream_volume_alias] - orifice = self.network.components[orifice_alias] - downstream_volume = self.network.components[downstream_volume_alias] - if not isinstance(upstream_volume, AmesimPneumaticVolume): - raise TypeError(f"{upstream_volume_alias} is not an AMESim pneumatic volume") - if not isinstance(downstream_volume, AmesimPneumaticVolume): - raise TypeError(f"{downstream_volume_alias} is not an AMESim pneumatic volume") - if not isinstance(orifice, AmesimPneumaticOrifice): - raise TypeError(f"{orifice_alias} is not an AMESim pneumatic orifice") - - def initial_state_vector() -> list[float]: - return [ - *upstream_volume.get_state_vector(), - *downstream_volume.get_state_vector(), - ] - - def apply_state_vector(values: list[float]) -> None: - if len(values) != 4: - raise ValueError("two-volume pneumatic branch state vector requires four values") - upstream_volume.set_state_vector(values[:2]) - downstream_volume.set_state_vector(values[2:]) - - branch_spec = spec or TestMqlPneumaticBranchSpec( - name=name, - upstream_volume_alias=upstream_volume_alias, - orifice_alias=orifice_alias, - downstream_volume_alias=downstream_volume_alias, - ) - return TestMqlPneumaticClosure( - components=TestMqlPneumaticBranchComponents( - name=name, - upstream_volume=upstream_volume, - orifice=orifice, - downstream_volume=downstream_volume, - spec=branch_spec, - ), - initial_state_vector=initial_state_vector, - apply_state_vector=apply_state_vector, - ) - - def simulate_pneumatic_branch( - self, - *, - name: str, - upstream_volume_alias: str, - orifice_alias: str, - downstream_volume_alias: str, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - closure = self.pneumatic_branch_closure( - name=name, - upstream_volume_alias=upstream_volume_alias, - orifice_alias=orifice_alias, - downstream_volume_alias=downstream_volume_alias, - ) - run_config = config or SolveIVPConfig(t_stop=1.0e-4, max_step=1.0e-5) - return integrate_ode( - rhs=lambda t, state: closure.rhs(state), - initial_state=closure.initial_state_vector(), - config=run_config, - t_eval=t_eval, - ) - - def simulate_pneumatic_branch_from_spec( - self, - spec, - *, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ): - return self.simulate_pneumatic_branch( - name=spec.name, - upstream_volume_alias=spec.upstream_volume_alias, - orifice_alias=spec.orifice_alias, - downstream_volume_alias=spec.downstream_volume_alias, - config=config, - t_eval=t_eval, - ) - - def evaluate_pneumatic_branch_solution_from_spec( - self, - solution: object, - spec, - ) -> dict[str, list[float]]: - return self.evaluate_pneumatic_branch_solution( - solution, - name=spec.name, - upstream_volume_alias=spec.upstream_volume_alias, - orifice_alias=spec.orifice_alias, - downstream_volume_alias=spec.downstream_volume_alias, - ) - - def evaluate_pneumatic_branch_solution( - self, - solution: object, - *, - name: str, - upstream_volume_alias: str, - orifice_alias: str, - downstream_volume_alias: str, - ) -> dict[str, list[float]]: - closure = self.pneumatic_branch_closure( - name=name, - upstream_volume_alias=upstream_volume_alias, - orifice_alias=orifice_alias, - downstream_volume_alias=downstream_volume_alias, - ) - series = { - "time": [], - f"{name}.flow": [], - f"{upstream_volume_alias}.p": [], - f"{upstream_volume_alias}.T": [], - f"{upstream_volume_alias}.m_flow": [], - f"{downstream_volume_alias}.p": [], - f"{downstream_volume_alias}.T": [], - f"{downstream_volume_alias}.m_flow": [], - } - for index, time_value in enumerate(solution.t): - state_vector = [row[index] for row in solution.y] - snapshot = closure.snapshot(state_vector) - series["time"].append(float(time_value)) - series[f"{name}.flow"].append(snapshot.flow) - series[f"{upstream_volume_alias}.p"].append(snapshot.upstream.p) - series[f"{upstream_volume_alias}.T"].append(snapshot.upstream.T) - series[f"{upstream_volume_alias}.m_flow"].append( - closure.components.upstream_volume.port_a.m_flow - ) - series[f"{downstream_volume_alias}.p"].append(snapshot.downstream.p) - series[f"{downstream_volume_alias}.T"].append(snapshot.downstream.T) - series[f"{downstream_volume_alias}.m_flow"].append( - closure.components.downstream_volume.port_a.m_flow - ) - return series - - def snapshot(self) -> TestMqlSnapshot: - return TestMqlSnapshot( - model_name=MODEL_NAME, - component_count=len(COMPONENT_SPECS), - connection_count=len(CONNECTION_SPECS), - continuous_state_count=int(MODEL_INFO.get("num_states", 0)), - discrete_state_count=int(MODEL_INFO.get("num_discrete_states", 0)), - global_parameters=dict(GLOBAL_PARAMETERS), - submodel_counts=dict(SUBMODEL_COUNTS), - ) - - def initial_state_vector(self) -> list[float]: - return [0.0] * int(MODEL_INFO.get("num_states", 0)) - - def simulate(self, config: TestMqlRunConfig | None = None) -> TestMqlSimulationResult: - run_config = config or TestMqlRunConfig() - times = run_config.sample_times() - state_count = int(MODEL_INFO.get("num_states", 0)) - y = [[0.0 for _ in times] for _ in range(state_count)] - series = self.evaluate_solution(SimpleNamespace(t=times, y=y)) - return TestMqlSimulationResult(t=times, y=y, series=series) - - def evaluate_solution(self, solution: object) -> dict[str, list[float]]: - times = [float(value) for value in solution.t] - return { - "time": times, - "component_count": [float(len(COMPONENT_SPECS)) for _ in times], - "connection_count": [float(len(CONNECTION_SPECS)) for _ in times], - "continuous_state_count": [float(MODEL_INFO.get("num_states", 0)) for _ in times], - "discrete_state_count": [float(MODEL_INFO.get("num_discrete_states", 0)) for _ in times], - } - - def component_specs_by_submodel(self) -> dict[str, list[dict[str, object]]]: - grouped: dict[str, list[dict[str, object]]] = {} - for spec in COMPONENT_SPECS: - grouped.setdefault(str(spec["submodel"]), []).append(spec) - return grouped - - def connection_specs_by_submodel(self) -> dict[str, list[dict[str, object]]]: - grouped: dict[str, list[dict[str, object]]] = {} - for spec in CONNECTION_SPECS: - grouped.setdefault(str(spec["submodel"]), []).append(spec) - return grouped - - -def build_test_mql() -> SimulationNetwork: - return TestMqlSystem().network diff --git a/app/simulation/examples/test_mql/topology.py b/app/simulation/examples/test_mql/topology.py deleted file mode 100644 index 4fac424..0000000 --- a/app/simulation/examples/test_mql/topology.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import re -import tarfile -import xml.etree.ElementTree as ET -from dataclasses import dataclass -from pathlib import Path - - -@dataclass(frozen=True) -class TestMqlComponentContact: - component_a: str - port_a: str - component_b: str - port_b: str - - def other_endpoint(self, component_alias: str) -> tuple[str, str]: - if component_alias == self.component_a: - return self.component_b, self.port_b - if component_alias == self.component_b: - return self.component_a, self.port_a - raise KeyError(component_alias) - - def port_for(self, component_alias: str) -> str: - if component_alias == self.component_a: - return self.port_a - if component_alias == self.component_b: - return self.port_b - raise KeyError(component_alias) - - -@dataclass(frozen=True) -class TestMqlCirTopology: - component_contacts: tuple[TestMqlComponentContact, ...] - - def contacts_for(self, component_alias: str) -> tuple[TestMqlComponentContact, ...]: - return tuple( - contact - for contact in self.component_contacts - if component_alias in (contact.component_a, contact.component_b) - ) - - -def load_test_mql_cir_topology( - archive_path: str | Path, - *, - cir_member: str = "test_mql_.cir", -) -> TestMqlCirTopology: - with tarfile.open(archive_path) as archive: - cir_file = archive.extractfile(cir_member) - if cir_file is None: - raise ValueError(f"Missing AMESim circuit member: {cir_member}") - cir_text = cir_file.read().decode("latin1") - - root = ET.fromstring(_topology_only_xml(cir_text)) - components = root.findall(".//COMPS_LIST/COMP") - aliases = tuple(_required_text(component, "ALIAS") for component in components) - contacts: dict[ - tuple[tuple[int, int], tuple[int, int]], - TestMqlComponentContact, - ] = {} - directed_contacts: set[tuple[tuple[int, int], tuple[int, int]]] = set() - - for component_index, component in enumerate(components): - ports = component.findall("./COMP_PORTS_LIST/COMP_PORT") - for port_index, port in enumerate(ports): - if port.findtext("PORT_CONNECT") != "1": - continue - for connection in port.findall("./CONNECT_LIST/CONNECT"): - target_index = int(_required_text(connection, "CONNECT_ENTITY_NUM")) - target_port_index = int(_required_text(connection, "CONNECT_ENTITY_PORT")) - if target_index < 0 or target_index >= len(components): - raise ValueError(f"Component contact references unknown entity {target_index}") - target_ports = components[target_index].findall("./COMP_PORTS_LIST/COMP_PORT") - if target_port_index < 0 or target_port_index >= len(target_ports): - raise ValueError( - f"Component contact references unknown port {target_port_index} " - f"on {aliases[target_index]}" - ) - - endpoint = (component_index, port_index) - target_endpoint = (target_index, target_port_index) - directed_contacts.add((endpoint, target_endpoint)) - key = tuple(sorted((endpoint, target_endpoint))) - first, second = key - contacts[key] = TestMqlComponentContact( - component_a=aliases[first[0]], - port_a=f"port_{first[1] + 1}", - component_b=aliases[second[0]], - port_b=f"port_{second[1] + 1}", - ) - - for endpoint, target_endpoint in directed_contacts: - if (target_endpoint, endpoint) not in directed_contacts: - raise ValueError( - "AMESim component contact is not reciprocal: " - f"{endpoint} -> {target_endpoint}" - ) - - return TestMqlCirTopology(component_contacts=tuple(contacts.values())) - - -def _topology_only_xml(cir_text: str) -> str: - # AMESim expressions inside SUBMODEL contain unescaped && and <= operators. - # Topology lives outside those blocks, so omit them before XML parsing. - return re.sub( - r".*?", - "", - cir_text, - flags=re.DOTALL, - ) - - -def _required_text(element: ET.Element, child_name: str) -> str: - value = element.findtext(child_name) - if value is None: - raise ValueError(f"Missing AMESim circuit element: {child_name}") - return value diff --git a/app/simulation/examples/testmodel/__init__.py b/app/simulation/examples/testmodel/__init__.py deleted file mode 100644 index 027fceb..0000000 --- a/app/simulation/examples/testmodel/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Legacy TestModel reference system.""" diff --git a/app/simulation/examples/testmodel/closure.py b/app/simulation/examples/testmodel/closure.py deleted file mode 100644 index 142f40e..0000000 --- a/app/simulation/examples/testmodel/closure.py +++ /dev/null @@ -1,668 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Callable - -from app.simulation.components.experimental.flow.orifice import Orifice -from app.simulation.examples.testmodel.dynamic_pipe import Pipe -from app.simulation.components.experimental.junctions.tee import Tee -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties -from app.simulation.core.state import VolumeState - - -@dataclass(frozen=True) -class BranchInletFlowDiagnostics: - converged: bool - iterations: int - residual: float - m_flow: float - inlet_pressure: float - - -@dataclass(frozen=True) -class DownstreamPressureDiagnostics: - converged: bool - iterations: int - residual: float - pressure: float - target_total_internal_energy: float - - -@dataclass(frozen=True) -class TestModelSolveDiagnostics: - upper_branch_inlet: BranchInletFlowDiagnostics - lower_branch_inlet: BranchInletFlowDiagnostics - downstream_pressure_projection: DownstreamPressureDiagnostics | None - - -@dataclass(frozen=True) -class BranchClosureComponents: - name: str - orifice: Orifice - pipe: Pipe - - -@dataclass(frozen=True) -class BranchClosureState: - name: str - pipe: ThermodynamicProperties - inlet_flow: float - outlet_flow: float - inlet_h: float - inlet_flow_diagnostics: BranchInletFlowDiagnostics - - -@dataclass(frozen=True) -class BranchSnapshot: - name: str - pipe: ThermodynamicProperties - inlet_flow: float - outlet_flow: float - inlet_h: float - inlet_flow_diagnostics: BranchInletFlowDiagnostics - - -@dataclass(frozen=True) -class TestModelSnapshot: - cylinder: ThermodynamicProperties - tank: ThermodynamicProperties - tee_upstream_h: float - tee_downstream_h: float - branches: tuple[BranchSnapshot, ...] = field(default_factory=tuple) - solve_diagnostics: TestModelSolveDiagnostics | None = None - - @property - def pipe_upper(self) -> ThermodynamicProperties: - return self.branches[0].pipe - - @property - def pipe_lower(self) -> ThermodynamicProperties: - return self.branches[1].pipe - - @property - def branch_inlet_flows(self) -> tuple[float, ...]: - return tuple(branch.inlet_flow for branch in self.branches) - - @property - def branch_outlet_flows(self) -> tuple[float, ...]: - return tuple(branch.outlet_flow for branch in self.branches) - - -@dataclass(frozen=True) -class InitializationDiagnostics: - converged: bool - iterations: int - max_state_delta: float - max_flow_delta: float - max_enthalpy_delta: float - downstream_pressure_spread: float - state_vector: tuple[float, ...] - - -@dataclass(frozen=True) -class TestModelClosureComponents: - cylinder: Cylinder - upstream_tee: Tee - upper_branch: BranchClosureComponents - lower_branch: BranchClosureComponents - downstream_tee: Tee - tank: Tank - - def branches(self) -> tuple[BranchClosureComponents, BranchClosureComponents]: - return (self.upper_branch, self.lower_branch) - - -class TestModelClosure: - """Owns Testmodel-specific closure, projection and port-writeback logic.""" - - def __init__( - self, - *, - medium: IdealGasMedium, - components: TestModelClosureComponents, - initial_state_vector: Callable[[], list[float]], - apply_state_vector: Callable[[list[float]], None], - ) -> None: - self.medium = medium - self.components = components - self._initial_state_vector = initial_state_vector - self._apply_state_vector = apply_state_vector - self.last_solve_diagnostics: TestModelSolveDiagnostics | None = None - self.last_downstream_pressure_diagnostics: DownstreamPressureDiagnostics | None = None - - @staticmethod - def _downstream_pressure_spread(snapshot: TestModelSnapshot) -> float: - downstream_pressures = tuple(branch.pipe.p for branch in snapshot.branches) + ( - snapshot.tank.p, - ) - return max(downstream_pressures) - min(downstream_pressures) - - @staticmethod - def _initialization_flow_delta( - previous_snapshot: TestModelSnapshot | None, - current_snapshot: TestModelSnapshot, - ) -> float: - if previous_snapshot is None: - return max(abs(branch.outlet_flow) for branch in current_snapshot.branches) - return max( - abs(curr - prev) - for curr, prev in zip( - current_snapshot.branch_outlet_flows, - previous_snapshot.branch_outlet_flows, - ) - ) - - @staticmethod - def _initialization_enthalpy_delta( - previous_snapshot: TestModelSnapshot | None, - current_snapshot: TestModelSnapshot, - ) -> float: - if previous_snapshot is None: - return abs(current_snapshot.tee_downstream_h - current_snapshot.tank.h) - return max( - abs(current_snapshot.tee_upstream_h - previous_snapshot.tee_upstream_h), - abs(current_snapshot.tee_downstream_h - previous_snapshot.tee_downstream_h), - ) - - def consistent_initial_state_vector(self) -> list[float]: - return list(self.initialize_consistent_state().state_vector) - - def initialize_consistent_state( - self, - max_iterations: int = 12, - state_tolerance: float = 1e-9, - flow_tolerance: float = 1e-9, - enthalpy_tolerance: float = 1e-6, - pressure_tolerance: float = 1e-6, - strict_internal_solvers: bool = False, - ) -> InitializationDiagnostics: - raw_state = self._initial_state_vector() - previous_snapshot: TestModelSnapshot | None = None - diagnostics: InitializationDiagnostics | None = None - - for iteration in range(1, max_iterations + 1): - state_before_projection = self._initial_state_vector() - self.snapshot(state_before_projection, strict=strict_internal_solvers) - - self.project_downstream_pressure_constraints(strict=strict_internal_solvers) - - state_after_projection = self._initial_state_vector() - snapshot_after_projection = self.snapshot( - state_after_projection, - strict=strict_internal_solvers, - ) - - max_state_delta = max( - abs(after - before) - for before, after in zip(state_before_projection, state_after_projection) - ) - max_flow_delta = self._initialization_flow_delta( - previous_snapshot, - snapshot_after_projection, - ) - max_enthalpy_delta = self._initialization_enthalpy_delta( - previous_snapshot, - snapshot_after_projection, - ) - downstream_pressure_spread = self._downstream_pressure_spread( - snapshot_after_projection, - ) - - diagnostics = InitializationDiagnostics( - converged=( - max_state_delta <= state_tolerance - and max_flow_delta <= flow_tolerance - and max_enthalpy_delta <= enthalpy_tolerance - and downstream_pressure_spread <= pressure_tolerance - ), - iterations=iteration, - max_state_delta=max_state_delta, - max_flow_delta=max_flow_delta, - max_enthalpy_delta=max_enthalpy_delta, - downstream_pressure_spread=downstream_pressure_spread, - state_vector=tuple(state_after_projection), - ) - previous_snapshot = snapshot_after_projection - - if diagnostics.converged: - self._apply_state_vector(raw_state) - return diagnostics - - assert diagnostics is not None - self._apply_state_vector(raw_state) - return diagnostics - - def _solve_branch_inlet_flow( - self, - orifice: Orifice, - pipe: Pipe, - p_upstream: float, - pipe_props: ThermodynamicProperties, - *, - strict: bool = False, - ) -> tuple[float, BranchInletFlowDiagnostics]: - m_flow = orifice.mass_flow(p_upstream, pipe_props.p) - rho = max(pipe_props.rho, 1e-9) - p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p) - residual = abs(orifice.mass_flow(p_upstream, p_inlet) - m_flow) - converged = False - iterations = 0 - for iteration in range(1, 9): - p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p) - next_m_flow = orifice.mass_flow(p_upstream, p_inlet) - residual = abs(next_m_flow - m_flow) - iterations = iteration - if residual <= 1e-9 * max(1.0, abs(next_m_flow)): - m_flow = next_m_flow - converged = True - break - m_flow = next_m_flow - diagnostics = BranchInletFlowDiagnostics( - converged=converged, - iterations=iterations, - residual=residual, - m_flow=m_flow, - inlet_pressure=p_inlet, - ) - if strict and not diagnostics.converged: - raise RuntimeError( - f"Branch inlet flow solve did not converge for {pipe.name}: residual={residual:.6e}" - ) - return m_flow, diagnostics - - def _solve_downstream_branch_flows( - self, - cylinder: ThermodynamicProperties, - tank: ThermodynamicProperties, - branch_states: tuple[BranchClosureState, BranchClosureState], - ) -> tuple[float, float]: - return self._solve_downstream_branch_flows_from_state( - inlet_h_upper=branch_states[0].inlet_h, - inlet_h_lower=branch_states[1].inlet_h, - pipe_upper_h=max(branch_states[0].pipe.h, 1e-9), - pipe_lower_h=max(branch_states[1].pipe.h, 1e-9), - tank_h=max(tank.h, 1e-9), - q_in_upper=branch_states[0].inlet_flow, - q_in_lower=branch_states[1].inlet_flow, - ) - - def _project_volume_energy_to_pressure( - self, - component: Pipe | Tank, - target_pressure: float, - ) -> None: - target_temperature = target_pressure * component.V / ( - max(component.state.m, 1e-12) * self.medium.R_gas - ) - target_internal_energy = ( - component.state.m * self.medium.specific_internal_energy(target_temperature) - ) - component.state = VolumeState(m=component.state.m, U=target_internal_energy) - - def _downstream_total_internal_energy_for_pressure( - self, - target_pressure: float, - downstream_components: tuple[Pipe | Tank, ...], - ) -> float: - total_internal_energy = 0.0 - for component in downstream_components: - target_temperature = target_pressure * component.V / ( - max(component.state.m, 1e-12) * self.medium.R_gas - ) - total_internal_energy += ( - component.state.m * self.medium.specific_internal_energy(target_temperature) - ) - return total_internal_energy - - def _solve_downstream_common_pressure( - self, - downstream_components: tuple[Pipe | Tank, ...], - target_total_internal_energy: float, - *, - strict: bool = False, - ) -> tuple[float, DownstreamPressureDiagnostics]: - lower_pressure = 1.0 - upper_pressure = max(component.properties().p for component in downstream_components) - upper_pressure = max(upper_pressure, 1e5) - - def residual(pressure: float) -> float: - return ( - self._downstream_total_internal_energy_for_pressure( - pressure, - downstream_components, - ) - - target_total_internal_energy - ) - - upper_residual = residual(upper_pressure) - iteration_count = 0 - - while upper_residual < 0.0: - upper_pressure *= 2.0 - upper_residual = residual(upper_pressure) - - final_pressure = 0.5 * (lower_pressure + upper_pressure) - final_residual = residual(final_pressure) - converged = False - for iteration in range(1, 81): - middle_pressure = 0.5 * (lower_pressure + upper_pressure) - middle_residual = residual(middle_pressure) - iteration_count = iteration - final_pressure = middle_pressure - final_residual = middle_residual - if abs(middle_residual) <= 1e-12 * max(1.0, target_total_internal_energy): - converged = True - break - if middle_residual > 0.0: - upper_pressure = middle_pressure - else: - lower_pressure = middle_pressure - - diagnostics = DownstreamPressureDiagnostics( - converged=converged, - iterations=iteration_count, - residual=final_residual, - pressure=final_pressure, - target_total_internal_energy=target_total_internal_energy, - ) - if strict and not diagnostics.converged: - raise RuntimeError( - "Downstream common-pressure solve did not converge: " - f"residual={final_residual:.6e}" - ) - return final_pressure, diagnostics - - def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None: - downstream_components = ( - self.components.upper_branch.pipe, - self.components.lower_branch.pipe, - self.components.tank, - ) - total_internal_energy = sum(component.state.U for component in downstream_components) - common_pressure, diagnostics = self._solve_downstream_common_pressure( - downstream_components, - total_internal_energy, - strict=strict, - ) - self.last_downstream_pressure_diagnostics = diagnostics - - for component in downstream_components: - self._project_volume_energy_to_pressure(component, common_pressure) - - def _downstream_connection_enthalpy( - self, - q_out_upper: float, - q_out_lower: float, - pipe_upper_h: float, - pipe_lower_h: float, - tank_h: float, - ) -> float: - return self.components.downstream_tee.inlet_stream_enthalpy( - q_out_lower, - pipe_lower_h, - q_out_upper, - pipe_upper_h, - fallback_h=tank_h, - ) - - def _solve_downstream_branch_flows_from_state( - self, - *, - inlet_h_upper: float, - inlet_h_lower: float, - pipe_upper_h: float, - pipe_lower_h: float, - tank_h: float, - q_in_upper: float, - q_in_lower: float, - ) -> tuple[float, float]: - return self.components.downstream_tee.solve_branch_outlet_flows_from_energy_balance( - ratio_branch1=self.components.upper_branch.pipe.V / self.components.tank.V, - ratio_branch2=self.components.lower_branch.pipe.V / self.components.tank.V, - inlet_h_branch1=inlet_h_upper, - inlet_h_branch2=inlet_h_lower, - branch1_h=pipe_upper_h, - branch2_h=pipe_lower_h, - inlet_h=tank_h, - q_in_branch1=q_in_upper, - q_in_branch2=q_in_lower, - ) - - def _evaluate_branch_states( - self, - cylinder: ThermodynamicProperties, - ) -> tuple[BranchClosureState, BranchClosureState]: - states: list[BranchClosureState] = [] - for branch in self.components.branches(): - pipe_properties = branch.pipe.properties() - inlet_flow, inlet_flow_diagnostics = self._solve_branch_inlet_flow( - branch.orifice, - branch.pipe, - cylinder.p, - pipe_properties, - ) - inlet_h = branch.pipe.port_a_inlet_enthalpy( - port_a_m_flow=inlet_flow, - connected_h=cylinder.h, - internal_h=pipe_properties.h, - ) - states.append( - BranchClosureState( - name=branch.name, - pipe=pipe_properties, - inlet_flow=inlet_flow, - outlet_flow=0.0, - inlet_h=inlet_h, - inlet_flow_diagnostics=inlet_flow_diagnostics, - ) - ) - return (states[0], states[1]) - - @staticmethod - def _with_branch_outlet_flows( - branch_states: tuple[BranchClosureState, BranchClosureState], - outlet_flows: tuple[float, float], - ) -> tuple[BranchClosureState, BranchClosureState]: - return ( - BranchClosureState( - name=branch_states[0].name, - pipe=branch_states[0].pipe, - inlet_flow=branch_states[0].inlet_flow, - outlet_flow=outlet_flows[0], - inlet_h=branch_states[0].inlet_h, - inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics, - ), - BranchClosureState( - name=branch_states[1].name, - pipe=branch_states[1].pipe, - inlet_flow=branch_states[1].inlet_flow, - outlet_flow=outlet_flows[1], - inlet_h=branch_states[1].inlet_h, - inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics, - ), - ) - - @staticmethod - def _branch_snapshots( - branch_states: tuple[BranchClosureState, BranchClosureState], - ) -> tuple[BranchSnapshot, BranchSnapshot]: - return ( - BranchSnapshot( - name=branch_states[0].name, - pipe=branch_states[0].pipe, - inlet_flow=branch_states[0].inlet_flow, - outlet_flow=branch_states[0].outlet_flow, - inlet_h=branch_states[0].inlet_h, - inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics, - ), - BranchSnapshot( - name=branch_states[1].name, - pipe=branch_states[1].pipe, - inlet_flow=branch_states[1].inlet_flow, - outlet_flow=branch_states[1].outlet_flow, - inlet_h=branch_states[1].inlet_h, - inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics, - ), - ) - - def snapshot( - self, - state_vector: list[float] | None = None, - *, - strict: bool = False, - ) -> TestModelSnapshot: - if state_vector is not None: - self._apply_state_vector(list(state_vector)) - - cylinder = self.components.cylinder.properties() - tank = self.components.tank.properties() - branch_states = self._evaluate_branch_states(cylinder) - if strict: - for branch_state in branch_states: - if not branch_state.inlet_flow_diagnostics.converged: - raise RuntimeError( - "Branch inlet flow solve did not converge for " - f"{branch_state.name}: residual=" - f"{branch_state.inlet_flow_diagnostics.residual:.6e}" - ) - outlet_flows = self._solve_downstream_branch_flows(cylinder, tank, branch_states) - branch_states = self._with_branch_outlet_flows(branch_states, outlet_flows) - - tee_upstream_h = self.components.upstream_tee.inlet_stream_enthalpy( - -branch_states[0].inlet_flow, - branch_states[0].pipe.h, - -branch_states[1].inlet_flow, - branch_states[1].pipe.h, - fallback_h=cylinder.h, - ) - tee_downstream_h = self._downstream_connection_enthalpy( - branch_states[0].outlet_flow, - branch_states[1].outlet_flow, - branch_states[0].pipe.h, - branch_states[1].pipe.h, - tank.h, - ) - - self._write_port_states( - cylinder, - tank, - branch_states, - tee_upstream_h, - tee_downstream_h, - ) - - solve_diagnostics = TestModelSolveDiagnostics( - upper_branch_inlet=branch_states[0].inlet_flow_diagnostics, - lower_branch_inlet=branch_states[1].inlet_flow_diagnostics, - downstream_pressure_projection=self.last_downstream_pressure_diagnostics, - ) - self.last_solve_diagnostics = solve_diagnostics - branch_snapshots = self._branch_snapshots(branch_states) - - return TestModelSnapshot( - cylinder=cylinder, - tank=tank, - tee_upstream_h=tee_upstream_h, - tee_downstream_h=tee_downstream_h, - branches=branch_snapshots, - solve_diagnostics=solve_diagnostics, - ) - - def _write_port_states( - self, - cylinder: ThermodynamicProperties, - tank: ThermodynamicProperties, - branch_states: tuple[BranchClosureState, BranchClosureState], - tee_upstream_h: float, - tee_downstream_h: float, - ) -> None: - cylinder_m_flow = -sum(branch_state.inlet_flow for branch_state in branch_states) - tank_m_flow = sum(branch_state.outlet_flow for branch_state in branch_states) - - self.components.cylinder.port_b.m_flow = cylinder_m_flow - - self.components.upstream_tee.port_in.p = cylinder.p - self.components.upstream_tee.port_out1.p = cylinder.p - self.components.upstream_tee.port_out2.p = cylinder.p - self.components.upstream_tee.port_in.m_flow = -cylinder_m_flow - self.components.upstream_tee.port_in.h_outflow = tee_upstream_h - self.components.upstream_tee.port_out1.h_outflow = cylinder.h - self.components.upstream_tee.port_out2.h_outflow = cylinder.h - self.components.upstream_tee.port_out1.m_flow = -branch_states[0].inlet_flow - self.components.upstream_tee.port_out2.m_flow = -branch_states[1].inlet_flow - - for branch_components, branch_state in zip(self.components.branches(), branch_states): - branch_components.orifice.port_a.p = cylinder.p - branch_components.orifice.port_b.p = branch_components.pipe.inlet_pressure( - branch_state.inlet_flow, - max(branch_state.pipe.rho, 1e-9), - branch_state.pipe.p, - ) - branch_components.orifice.port_a.m_flow = branch_state.inlet_flow - branch_components.orifice.port_b.m_flow = -branch_state.inlet_flow - branch_components.orifice.port_a.h_outflow = cylinder.h - branch_components.orifice.port_b.h_outflow = branch_state.pipe.h - - branch_components.pipe.port_a.p = branch_components.orifice.port_b.p - branch_components.pipe.port_a.m_flow = branch_state.inlet_flow - branch_components.pipe.port_b.m_flow = -branch_state.outlet_flow - branch_components.pipe.port_b.p = branch_state.pipe.p - - self.components.downstream_tee.port_in.p = tank.p - self.components.downstream_tee.port_out1.p = tank.p - self.components.downstream_tee.port_out2.p = tank.p - self.components.downstream_tee.port_in.m_flow = -tank_m_flow - self.components.downstream_tee.port_out1.m_flow = branch_states[1].outlet_flow - self.components.downstream_tee.port_out2.m_flow = branch_states[0].outlet_flow - self.components.downstream_tee.port_in.h_outflow = tee_downstream_h - self.components.downstream_tee.port_out1.h_outflow = tank.h - self.components.downstream_tee.port_out2.h_outflow = tank.h - - self.components.tank.port_a.m_flow = tank_m_flow - - def _branch_derivative_states( - self, - snapshot: TestModelSnapshot, - ) -> tuple[VolumeState, VolumeState]: - derivative_states: list[VolumeState] = [] - for branch_components, branch_snapshot in zip(self.components.branches(), snapshot.branches): - derivative_states.append( - branch_components.pipe.derivatives_from_connections( - port_a_m_flow=branch_snapshot.inlet_flow, - connected_h_a=snapshot.cylinder.h, - port_b_m_flow=-branch_snapshot.outlet_flow, - connected_h_b=snapshot.tank.h, - internal_h=branch_snapshot.pipe.h, - ) - ) - return (derivative_states[0], derivative_states[1]) - - def rhs(self, state_vector: list[float]) -> list[float]: - snapshot = self.snapshot(state_vector) - - cylinder_m_flow = -sum(branch.inlet_flow for branch in snapshot.branches) - tank_m_flow = sum(branch.outlet_flow for branch in snapshot.branches) - d_cylinder = self.components.cylinder.derivatives_from_connection( - connected_h=snapshot.tee_upstream_h, - port_m_flow=cylinder_m_flow, - internal_h=snapshot.cylinder.h, - ) - branch_derivatives = self._branch_derivative_states(snapshot) - d_tank = self.components.tank.derivatives_from_connection( - connected_h=snapshot.tee_downstream_h, - port_m_flow=tank_m_flow, - internal_h=snapshot.tank.h, - ) - - return [ - d_cylinder.m, - d_cylinder.U, - branch_derivatives[0].m, - branch_derivatives[0].U, - branch_derivatives[1].m, - branch_derivatives[1].U, - d_tank.m, - d_tank.U, - ] diff --git a/app/simulation/examples/testmodel/dynamic_pipe.py b/app/simulation/examples/testmodel/dynamic_pipe.py deleted file mode 100644 index 7f388e6..0000000 --- a/app/simulation/examples/testmodel/dynamic_pipe.py +++ /dev/null @@ -1,272 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping - -from app.simulation.core.base import ThermodynamicVolumeComponent -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ( - ParameterDefinition, - THERMODYNAMIC_VOLUME_RESULT_VARIABLES, -) -from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties -from app.simulation.core.ports import PortDefinition -from app.simulation.core.state import VolumeState - - -class Pipe(ThermodynamicVolumeComponent): - """Dynamic pipe retained for the fixed TestModel compatibility example.""" - - MODEL_TYPE = "pipe" - MODEL_VERSION = "0.1.0" - PORTS = ( - PortDefinition.pneumatic("port_a", nominal_role="inlet"), - PortDefinition.pneumatic("port_b", nominal_role="outlet"), - ) - PARAMETERS = ( - ParameterDefinition( - "length", - 5.0, - label="长度", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "diameter", - 0.02, - label="直径", - quantity="length", - unit="m", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "lambda_darcy", - 0.02, - label="摩阻系数", - minimum=0.0, - ), - ParameterDefinition( - "p0", - 1e5, - label="初始压力", - quantity="pressure", - unit="Pa", - minimum=0.0, - minimum_exclusive=True, - ), - ParameterDefinition( - "T0", - 300.0, - label="初始温度", - quantity="temperature", - unit="K", - minimum=0.0, - minimum_exclusive=True, - ), - ) - RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES - - def __init__( - self, - name: str, - medium: IdealGasMedium, - L: float = 5.0, - D: float = 0.02, - lambda_darcy: float = 0.02, - p0: float = 1e5, - T0: float = 300.0, - ) -> None: - super().__init__(name=name) - self.set_parameter_values( - { - "length": L, - "diameter": D, - "lambda_darcy": lambda_darcy, - "p0": p0, - "T0": T0, - } - ) - self.medium = medium - self.L = L - self.D = D - self.lambda_darcy = lambda_darcy - self.area = 3.141592653589793 * D * D / 4.0 - self.V = self.area * L - m0 = p0 * self.V / (medium.R_gas * T0) - U0 = m0 * medium.specific_internal_energy(T0) - self.state = VolumeState(m=m0, U=U0) - self.port_a = self.register_declared_port("port_a") - self.port_b = self.register_declared_port("port_b") - - @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> Pipe: - return cls( - name=name, - medium=medium, - L=parameters["length"], - D=parameters["diameter"], - lambda_darcy=parameters["lambda_darcy"], - p0=parameters["p0"], - T0=parameters["T0"], - ) - - def get_state_vector(self) -> list[float]: - return self.state.as_vector() - - def set_state_vector(self, values: list[float]) -> None: - self.state = VolumeState.from_vector(values) - - def properties(self) -> ThermodynamicProperties: - props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V) - self.port_b.p = props.p - self.port_a.h_outflow = props.h - self.port_b.h_outflow = props.h - return props - - def refresh_thermodynamic_ports(self) -> ThermodynamicProperties: - return self.properties() - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - properties = self.properties() - derivative = self.derivatives_from_connections( - port_a_m_flow=self.port_a.m_flow, - connected_h_a=connected_h["port_a"], - port_b_m_flow=self.port_b.m_flow, - connected_h_b=connected_h["port_b"], - internal_h=properties.h, - ) - return derivative.as_vector() - - def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float: - resistance = self.lambda_darcy * (self.L / self.D) - dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area) - return core_pressure + resistance * dynamic_term - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - properties = self.medium.properties_from_mU( - self.state.m, - self.state.U, - self.V, - ) - expected_inlet_pressure = self.inlet_pressure( - self.port_a.m_flow, - max(properties.rho, 1e-12), - properties.p, - ) - return ( - EquationResidual( - id=f"{self.name}:darcy_pressure_loss", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_a.p", - f"{self.name}.port_a.m_flow", - f"{self.name}.state", - ), - role="effort", - value=self.port_a.p - expected_inlet_pressure, - ), - EquationResidual( - id=f"{self.name}:port_b_pressure_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_b.p", f"{self.name}.state"), - role="effort", - value=self.port_b.p - properties.p, - ), - ) - - def port_a_inlet_enthalpy( - self, - *, - port_a_m_flow: float, - connected_h: float, - internal_h: float, - ) -> float: - return self.connection_inlet_enthalpy( - port_m_flow=port_a_m_flow, - connected_h=connected_h, - internal_h=internal_h, - ) - - def port_b_inlet_enthalpy( - self, - *, - port_b_m_flow: float, - connected_h: float, - internal_h: float, - ) -> float: - return self.connection_inlet_enthalpy( - port_m_flow=port_b_m_flow, - connected_h=connected_h, - internal_h=internal_h, - ) - - def connection_inlet_enthalpies( - self, - *, - port_a_m_flow: float, - connected_h_a: float, - port_b_m_flow: float, - connected_h_b: float, - internal_h: float, - ) -> tuple[float, float]: - return ( - self.port_a_inlet_enthalpy( - port_a_m_flow=port_a_m_flow, - connected_h=connected_h_a, - internal_h=internal_h, - ), - self.port_b_inlet_enthalpy( - port_b_m_flow=port_b_m_flow, - connected_h=connected_h_b, - internal_h=internal_h, - ), - ) - - def derivatives_from_connections( - self, - *, - port_a_m_flow: float, - connected_h_a: float, - port_b_m_flow: float, - connected_h_b: float, - internal_h: float, - ) -> VolumeState: - inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies( - port_a_m_flow=port_a_m_flow, - connected_h_a=connected_h_a, - port_b_m_flow=port_b_m_flow, - connected_h_b=connected_h_b, - internal_h=internal_h, - ) - return self.derivatives( - inlet_h_a=inlet_h_a, - inlet_h_b=inlet_h_b, - m_flow_a=port_a_m_flow, - m_flow_b=port_b_m_flow, - ) - - def derivatives( - self, - inlet_h_a: float, - inlet_h_b: float, - m_flow_a: float, - m_flow_b: float, - ) -> VolumeState: - dm_dt = m_flow_a + m_flow_b - dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b - return VolumeState(m=dm_dt, U=dU_dt) diff --git a/app/simulation/examples/testmodel/run.py b/app/simulation/examples/testmodel/run.py deleted file mode 100644 index 7a7a0f0..0000000 --- a/app/simulation/examples/testmodel/run.py +++ /dev/null @@ -1,222 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import UTC, datetime -from pathlib import Path - -from app.simulation.examples.testmodel.closure import TestModelSolveDiagnostics -from app.simulation.examples.testmodel.system import ( - InitializationDiagnostics, - TestModelConfig, - TestModelSystem, -) -from app.simulation.paths import ( - MODELICA_TESTMODEL_RESULT_PATH, - PROJECT_ROOT, - SIMULATION_RUNS_DIR, -) -from app.simulation.reporting import ( - COMPARISON_KEYS, - PRIMARY_KEYS, - TestModelArtifacts, - export_testmodel_artifacts, - format_testmodel_run_report, - load_modelica_series, - write_testmodel_run_report, -) -from app.simulation.solvers.solver import SolveIVPConfig - - -@dataclass(frozen=True) -class TestModelSamplingConfig: - step: float = 0.1 - - -@dataclass(frozen=True) -class TestModelPathConfig: - output_dir: Path | None = None - modelica_result_path: Path | None = None - - -@dataclass(frozen=True) -class TestModelExecutionConfig: - use_modelica_reference_if_available: bool = True - - -@dataclass(frozen=True) -class TestModelRunConfig: - model: TestModelConfig = field(default_factory=TestModelConfig) - solver: SolveIVPConfig = field(default_factory=SolveIVPConfig) - sampling: TestModelSamplingConfig = field(default_factory=TestModelSamplingConfig) - paths: TestModelPathConfig = field(default_factory=TestModelPathConfig) - execution: TestModelExecutionConfig = field(default_factory=TestModelExecutionConfig) - - @property - def sample_step(self) -> float: - return self.sampling.step - - def sample_times(self) -> list[float]: - return _sample_times( - self.solver.t_start, - self.solver.t_stop, - step=self.sampling.step, - ) - - -@dataclass(frozen=True) -class PreparedTestModelRun: - run_config: TestModelRunConfig - repo_root: Path - output_dir: Path - modelica_result_path: Path - t_eval: tuple[float, ...] - use_modelica_reference_if_available: bool - modelica_reference_exists: bool - - -@dataclass(frozen=True) -class TestModelRunResult: - run_config: TestModelRunConfig - prepared_run: PreparedTestModelRun - system: TestModelSystem - initialization: InitializationDiagnostics - raw_initial_state: tuple[float, ...] - consistent_initial_state: tuple[float, ...] - solution: object - series: dict[str, list[float]] - solve_diagnostics: TestModelSolveDiagnostics | None - artifacts: TestModelArtifacts - comparison_summary: dict[str, tuple[float, float]] | None - used_modelica_reference: bool - - -def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]: - point_count = int(round((t_stop - t_start) / step)) - return [t_start + index * step for index in range(point_count + 1)] - - -def _default_run_output_dir() -> Path: - timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f") - return SIMULATION_RUNS_DIR / timestamp - - -def prepare_testmodel_run( - *, - run_config: TestModelRunConfig | None = None, - output_dir: Path | None = None, - modelica_result_path: Path | None = None, -) -> PreparedTestModelRun: - run_config = run_config or TestModelRunConfig() - resolved_output_dir = ( - output_dir - or run_config.paths.output_dir - or _default_run_output_dir() - ) - resolved_modelica_result_path = ( - modelica_result_path - or run_config.paths.modelica_result_path - or MODELICA_TESTMODEL_RESULT_PATH - ) - t_eval = tuple(run_config.sample_times()) - return PreparedTestModelRun( - run_config=run_config, - repo_root=PROJECT_ROOT, - output_dir=resolved_output_dir, - modelica_result_path=resolved_modelica_result_path, - t_eval=t_eval, - use_modelica_reference_if_available=run_config.execution.use_modelica_reference_if_available, - modelica_reference_exists=resolved_modelica_result_path.exists(), - ) - - -def run_prepared_testmodel(prepared_run: PreparedTestModelRun) -> TestModelRunResult: - run_config = prepared_run.run_config - system = TestModelSystem(config=run_config.model) - raw_initial_state = tuple(system.initial_state_vector()) - initialization = system.initialize_consistent_state() - consistent_initial_state = tuple(initialization.state_vector) - solution = system.simulate(config=run_config.solver, t_eval=list(prepared_run.t_eval)) - series = system.evaluate_solution(solution) - solve_diagnostics = system.last_solve_diagnostics - - modelica_series = None - used_modelica_reference = False - if ( - prepared_run.use_modelica_reference_if_available - and prepared_run.modelica_reference_exists - ): - modelica_series = load_modelica_series( - prepared_run.modelica_result_path, - COMPARISON_KEYS, - ) - used_modelica_reference = True - - artifacts, comparison_summary = export_testmodel_artifacts( - output_dir=prepared_run.output_dir, - series=series, - modelica_series=modelica_series, - ) - report_text = format_testmodel_run_report( - network_summary=system.network.summary(), - initialization=initialization, - raw_initial_state=raw_initial_state, - consistent_initial_state=consistent_initial_state, - solution=solution, - series=series, - solve_diagnostics=solve_diagnostics, - artifacts=artifacts, - comparison_summary=comparison_summary, - ) - write_testmodel_run_report(prepared_run.output_dir, report_text) - - return TestModelRunResult( - run_config=run_config, - prepared_run=prepared_run, - system=system, - initialization=initialization, - raw_initial_state=raw_initial_state, - consistent_initial_state=consistent_initial_state, - solution=solution, - series=series, - solve_diagnostics=solve_diagnostics, - artifacts=artifacts, - comparison_summary=comparison_summary, - used_modelica_reference=used_modelica_reference, - ) - - -def run_testmodel( - *, - run_config: TestModelRunConfig | None = None, - output_dir: Path | None = None, - modelica_result_path: Path | None = None, -) -> TestModelRunResult: - prepared_run = prepare_testmodel_run( - run_config=run_config, - output_dir=output_dir, - modelica_result_path=modelica_result_path, - ) - return run_prepared_testmodel(prepared_run) - - -def main() -> None: - run_config = TestModelRunConfig() - result = run_testmodel(run_config=run_config) - print( - format_testmodel_run_report( - network_summary=result.system.network.summary(), - initialization=result.initialization, - raw_initial_state=result.raw_initial_state, - consistent_initial_state=result.consistent_initial_state, - solution=result.solution, - series=result.series, - solve_diagnostics=result.solve_diagnostics, - artifacts=result.artifacts, - comparison_summary=result.comparison_summary, - ), - end="", - ) - - -if __name__ == "__main__": - main() diff --git a/app/simulation/examples/testmodel/system.py b/app/simulation/examples/testmodel/system.py deleted file mode 100644 index 8553450..0000000 --- a/app/simulation/examples/testmodel/system.py +++ /dev/null @@ -1,303 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from app.simulation.components.experimental.flow.orifice import Orifice -from app.simulation.components.experimental.junctions.tee import Tee -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.medium import IdealGasMedium -from app.simulation.examples.testmodel.closure import ( - BranchClosureComponents, - InitializationDiagnostics, - TestModelClosure, - TestModelClosureComponents, - TestModelSnapshot, -) -from app.simulation.examples.testmodel.dynamic_pipe import Pipe -from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode -from app.simulation.systems.network import SimulationNetwork - - -@dataclass(frozen=True) -class CylinderConfig: - volume: float = 0.01 - p0: float = 35e6 - T0: float = 300.0 - - -@dataclass(frozen=True) -class OrificeConfig: - K: float = 1e-5 - - -@dataclass(frozen=True) -class TankConfig: - volume: float = 0.1 - p0: float = 1e5 - T0: float = 300.0 - - -@dataclass(frozen=True) -class PipeConfig: - length: float = 5.0 - diameter: float = 0.02 - lambda_darcy: float = 0.02 - p0: float = 1e5 - T0: float = 300.0 - - -@dataclass(frozen=True) -class BranchConfig: - orifice: OrificeConfig = field(default_factory=OrificeConfig) - pipe: PipeConfig = field(default_factory=PipeConfig) - - -@dataclass(frozen=True) -class TestModelConfig: - cylinder: CylinderConfig = field(default_factory=CylinderConfig) - upper_branch: BranchConfig = field(default_factory=BranchConfig) - lower_branch: BranchConfig = field(default_factory=BranchConfig) - tank: TankConfig = field(default_factory=TankConfig) - - -class TestModelSystem: - """Runnable first-pass Python system for the current Testmodel topology. - - This version keeps the component split from the Modelica model while keeping - the downstream tee-tank pressure coupling in the ODE framework. The original - Modelica system is a tighter DAE because both pipe outlets discharge into an - ideal lossless junction directly connected to the tank. Here the branch - outlet flows are solved from a pressure-consistent energy balance so the - outlet is no longer driven by an arbitrary conductance parameter. - """ - - def __init__( - self, - medium: IdealGasMedium | None = None, - config: TestModelConfig | None = None, - ) -> None: - self.medium = medium or IdealGasMedium() - self.config = config or TestModelConfig() - - self.mycylinder = Cylinder( - name="mycylinder", - medium=self.medium, - V=self.config.cylinder.volume, - p0=self.config.cylinder.p0, - T0=self.config.cylinder.T0, - ) - self.mytee = Tee(name="mytee") - self.myorifice = Orifice(name="myorifice", K=self.config.upper_branch.orifice.K) - self.mypipe = Pipe( - name="mypipe", - medium=self.medium, - L=self.config.upper_branch.pipe.length, - D=self.config.upper_branch.pipe.diameter, - lambda_darcy=self.config.upper_branch.pipe.lambda_darcy, - p0=self.config.upper_branch.pipe.p0, - T0=self.config.upper_branch.pipe.T0, - ) - self.myorifice1 = Orifice(name="myorifice1", K=self.config.lower_branch.orifice.K) - self.mypipe1 = Pipe( - name="mypipe1", - medium=self.medium, - L=self.config.lower_branch.pipe.length, - D=self.config.lower_branch.pipe.diameter, - lambda_darcy=self.config.lower_branch.pipe.lambda_darcy, - p0=self.config.lower_branch.pipe.p0, - T0=self.config.lower_branch.pipe.T0, - ) - self.mytee1 = Tee(name="mytee1") - self.mytank = Tank( - name="mytank", - medium=self.medium, - V=self.config.tank.volume, - p0=self.config.tank.p0, - T0=self.config.tank.T0, - ) - - self.network = SimulationNetwork(name="Testmodel") - for component in ( - self.mycylinder, - self.mytee, - self.myorifice, - self.mypipe, - self.myorifice1, - self.mypipe1, - self.mytee1, - self.mytank, - ): - self.network.add_component(component) - - self.network.connect("mycylinder", "port_b", "mytee", "port_in") - self.network.connect("mytee", "port_out1", "myorifice", "port_a") - self.network.connect("myorifice", "port_b", "mypipe", "port_a") - self.network.connect("mypipe", "port_b", "mytee1", "port_out2") - self.network.connect("mytee", "port_out2", "myorifice1", "port_a") - self.network.connect("myorifice1", "port_b", "mypipe1", "port_a") - self.network.connect("mypipe1", "port_b", "mytee1", "port_out1") - self.network.connect("mytee1", "port_in", "mytank", "port_a") - - self.closure = TestModelClosure( - medium=self.medium, - components=TestModelClosureComponents( - cylinder=self.mycylinder, - upstream_tee=self.mytee, - upper_branch=BranchClosureComponents( - name="upper_branch", - orifice=self.myorifice, - pipe=self.mypipe, - ), - lower_branch=BranchClosureComponents( - name="lower_branch", - orifice=self.myorifice1, - pipe=self.mypipe1, - ), - downstream_tee=self.mytee1, - tank=self.mytank, - ), - initial_state_vector=self.initial_state_vector, - apply_state_vector=self.apply_state_vector, - ) - - def initial_state_vector(self) -> list[float]: - return self.network.initial_state_vector() - - def apply_state_vector(self, values: list[float]) -> None: - self.network.apply_state_vector(values) - - def consistent_initial_state_vector(self) -> list[float]: - return self.closure.consistent_initial_state_vector() - - @property - def last_solve_diagnostics(self): - return self.closure.last_solve_diagnostics - - def initialize_consistent_state( - self, - max_iterations: int = 12, - state_tolerance: float = 1e-9, - flow_tolerance: float = 1e-9, - enthalpy_tolerance: float = 1e-6, - pressure_tolerance: float = 1e-6, - strict_internal_solvers: bool = False, - ) -> InitializationDiagnostics: - return self.closure.initialize_consistent_state( - max_iterations=max_iterations, - state_tolerance=state_tolerance, - flow_tolerance=flow_tolerance, - enthalpy_tolerance=enthalpy_tolerance, - pressure_tolerance=pressure_tolerance, - strict_internal_solvers=strict_internal_solvers, - ) - - def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None: - self.closure.project_downstream_pressure_constraints(strict=strict) - - def snapshot( - self, - state_vector: list[float] | None = None, - *, - strict: bool = False, - ) -> TestModelSnapshot: - return self.closure.snapshot(state_vector, strict=strict) - - def rhs(self, _t: float, state_vector: list[float]) -> list[float]: - return self.closure.rhs(state_vector) - - @staticmethod - def _legacy_branch_series_key_map() -> tuple[tuple[str, str, str], tuple[str, str, str]]: - return ( - ("upper_branch", "branch_upper.in", "branch_upper.out"), - ("lower_branch", "branch_lower.in", "branch_lower.out"), - ) - - @classmethod - def _legacy_branch_series_keys_by_name(cls) -> dict[str, tuple[str, str]]: - return { - branch_name: (inlet_key, outlet_key) - for branch_name, inlet_key, outlet_key in cls._legacy_branch_series_key_map() - } - - @staticmethod - def _generic_branch_series_keys(branch_name: str) -> tuple[str, str, str]: - return ( - f"branch.{branch_name}.p", - f"branch.{branch_name}.in", - f"branch.{branch_name}.out", - ) - - @staticmethod - def _legacy_branch_pressure_keys_by_name() -> dict[str, str]: - return { - "upper_branch": "mypipe.p", - "lower_branch": "mypipe1.p", - } - - @classmethod - def _append_legacy_branch_series_aliases( - cls, - series: dict[str, list[float]], - ) -> dict[str, list[float]]: - legacy_branch_series_keys = cls._legacy_branch_series_keys_by_name() - legacy_branch_pressure_keys = cls._legacy_branch_pressure_keys_by_name() - for branch_name, (legacy_inlet_key, legacy_outlet_key) in legacy_branch_series_keys.items(): - pressure_key, generic_inlet_key, generic_outlet_key = cls._generic_branch_series_keys( - branch_name - ) - series[legacy_branch_pressure_keys[branch_name]] = list(series[pressure_key]) - series[legacy_inlet_key] = list(series[generic_inlet_key]) - series[legacy_outlet_key] = list(series[generic_outlet_key]) - return series - - def simulate( - self, - config: SolveIVPConfig | None = None, - t_eval: list[float] | None = None, - ) -> Any: - return integrate_ode( - rhs=self.rhs, - initial_state=self.consistent_initial_state_vector(), - config=config or SolveIVPConfig(), - t_eval=t_eval, - ) - - def evaluate_solution(self, solution: Any) -> dict[str, list[float]]: - series = { - "time": [], - "mycylinder.p": [], - "mycylinder.T": [], - "mytank.p": [], - "mytank.T": [], - } - for branch_name, _, _ in self._legacy_branch_series_key_map(): - pressure_key, inlet_key, outlet_key = self._generic_branch_series_keys(branch_name) - series[pressure_key] = [] - series[inlet_key] = [] - series[outlet_key] = [] - - for index, time_value in enumerate(solution.t): - state_vector = [row[index] for row in solution.y] - snapshot = self.snapshot(state_vector) - series["time"].append(float(time_value)) - series["mycylinder.p"].append(snapshot.cylinder.p) - series["mycylinder.T"].append(snapshot.cylinder.T) - series["mytank.p"].append(snapshot.tank.p) - series["mytank.T"].append(snapshot.tank.T) - for branch in snapshot.branches: - pressure_key, generic_inlet_key, generic_outlet_key = self._generic_branch_series_keys( - branch.name - ) - series[pressure_key].append(branch.pipe.p) - series[generic_inlet_key].append(branch.inlet_flow) - series[generic_outlet_key].append(branch.outlet_flow) - - return self._append_legacy_branch_series_aliases(series) - - -def build_testmodel() -> SimulationNetwork: - """Compatibility helper for callers that only need the topology.""" - - return TestModelSystem().network diff --git a/app/simulation/max_step_matrix.py b/app/simulation/max_step_matrix.py deleted file mode 100644 index 1b0ad44..0000000 --- a/app/simulation/max_step_matrix.py +++ /dev/null @@ -1,1247 +0,0 @@ -"""Reproducible horizon/max-step matrix runs for one authoritative System XML. - -The module deliberately delegates every simulation to -``execute_regression_case``. Consequently each cell inherits the existing -soft-cancel/hard-termination boundary and derives its XML in child-process -memory; the source XML is never rewritten. - -The two comparison axes answer different questions: - -* same horizon, different maximum steps: ordinary step-size sensitivity; -* same maximum step, different horizons: prefix invariance with respect to - ``tStop`` (a longer request should not change already-reached checkpoints). - -This is a diagnostic matrix, not a golden generator. Numerical state values -are retained in its JSON evidence but are never written back to a manifest. -""" - -from __future__ import annotations - -import argparse -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from datetime import UTC, datetime -from itertools import combinations -import json -import math -from pathlib import Path - -from app.simulation.benchmark_regression import ( - DEFAULT_MANIFEST_PATH, - RegressionCaseRequest, - RegressionManifestError, - execute_regression_case, - load_regression_manifest, - source_simulation_config, -) - - -MATRIX_REPORT_SCHEMA_VERSION = 1 -DEFAULT_HORIZON_CASE_IDS = ("1s", "5s", "10s") -DEFAULT_COMMON_CHECKPOINT_TIMES = (0.0, 0.04, 0.8, 1.0) -DEFAULT_EXPECTED_PROJECTION_COUNT = 134 - -MatrixCaseExecutor = Callable[[RegressionCaseRequest], dict[str, object]] - - -@dataclass(frozen=True) -class MatrixTolerance: - state_relative: float - state_absolute: float - checkpoint_time_absolute_seconds: float - event_time_absolute_seconds: float - signal_event_time_absolute_seconds: float - maximum_scaled_residual: float | None - - -@dataclass(frozen=True) -class MatrixHorizon: - case_id: str - source_variant_id: str | None - stop_time: float - checkpoint_times: tuple[float, ...] - expected_signal_event_times: tuple[float, ...] - soft_timeout_seconds: float - hard_timeout_seconds: float - - -def _finite_positive(value: object, *, field: str) -> float: - try: - numeric = float(value) - except (TypeError, ValueError) as exc: - raise RegressionManifestError(f"{field} must be numeric.") from exc - if not math.isfinite(numeric) or numeric <= 0.0: - raise RegressionManifestError(f"{field} must be finite and positive.") - return numeric - - -def _finite_nonnegative(value: object, *, field: str) -> float: - try: - numeric = float(value) - except (TypeError, ValueError) as exc: - raise RegressionManifestError(f"{field} must be numeric.") from exc - if not math.isfinite(numeric) or numeric < 0.0: - raise RegressionManifestError( - f"{field} must be finite and non-negative." - ) - return numeric - - -def _matrix_tolerance(manifest: Mapping[str, object]) -> MatrixTolerance: - correctness = manifest.get("correctness") - if not isinstance(correctness, Mapping): - raise RegressionManifestError("Manifest correctness must be an object.") - maximum_residual = correctness.get("maximumScaledResidual") - return MatrixTolerance( - state_relative=_finite_nonnegative( - correctness.get("stateRelativeTolerance", 0.0), - field="correctness.stateRelativeTolerance", - ), - state_absolute=_finite_nonnegative( - correctness.get("stateAbsoluteTolerance", 0.0), - field="correctness.stateAbsoluteTolerance", - ), - checkpoint_time_absolute_seconds=_finite_nonnegative( - correctness.get("checkpointTimeAbsoluteToleranceSeconds", 1.0e-12), - field="correctness.checkpointTimeAbsoluteToleranceSeconds", - ), - event_time_absolute_seconds=_finite_nonnegative( - correctness.get("eventTimeAbsoluteToleranceSeconds", 0.0), - field="correctness.eventTimeAbsoluteToleranceSeconds", - ), - signal_event_time_absolute_seconds=_finite_nonnegative( - correctness.get("signalEventTimeAbsoluteToleranceSeconds", 0.0), - field="correctness.signalEventTimeAbsoluteToleranceSeconds", - ), - maximum_scaled_residual=( - _finite_nonnegative( - maximum_residual, - field="correctness.maximumScaledResidual", - ) - if maximum_residual is not None - else None - ), - ) - - -def _resolve_horizons( - manifest: Mapping[str, object], - requested: Sequence[str | float], - *, - additional_checkpoint_times: Sequence[float], -) -> tuple[MatrixHorizon, ...]: - variants = manifest.get("variants") - sequence = manifest.get("sequence") - if not isinstance(variants, Mapping) or not isinstance(sequence, list): - raise RegressionManifestError("Manifest variants/sequence are incomplete.") - extra_checkpoints = tuple( - _finite_nonnegative(value, field=f"additionalCheckpointTimes[{index}]") - for index, value in enumerate(additional_checkpoint_times) - ) - ordered_variants: list[tuple[str, Mapping[str, object]]] = [] - for raw_case_id in sequence: - variant = variants.get(str(raw_case_id)) - if isinstance(variant, Mapping): - ordered_variants.append((str(raw_case_id), variant)) - selected: list[MatrixHorizon] = [] - for raw_request in requested: - request = str(raw_request).strip() - if request in variants: - source_variant_id: str | None = request - variant = variants[request] - assert isinstance(variant, Mapping) - stop_time = float(variant["stopTime"]) - else: - try: - stop_time = float(request.removesuffix("s")) - except ValueError as exc: - raise RegressionManifestError( - f"Unknown matrix horizon {raw_request!r}." - ) from exc - stop_time = _finite_positive(stop_time, field=f"horizon[{request}]") - matches = [ - (str(candidate), variant) - for candidate, variant in variants.items() - if isinstance(variant, Mapping) - and isinstance(variant.get("stopTime"), (int, float)) - and math.isclose( - float(variant["stopTime"]), - stop_time, - rel_tol=0.0, - abs_tol=max(1.0e-12, 8.0 * math.ulp(max(1.0, abs(stop_time)))), - ) - ] - if len(matches) > 1: - raise RegressionManifestError( - f"Matrix horizon {raw_request!r} identifies multiple variants." - ) - if matches: - source_variant_id, variant = matches[0] - else: - source_variant_id = None - variant = None - case_id = source_variant_id or f"{format(stop_time, '.12g')}s-custom" - if any(item.case_id == case_id for item in selected): - raise RegressionManifestError( - f"Matrix horizon {case_id!r} was selected more than once." - ) - - timeout_variant = variant - if timeout_variant is None: - enclosing = [ - candidate - for _, candidate in ordered_variants - if float(candidate["stopTime"]) >= stop_time - ] - timeout_variant = enclosing[0] if enclosing else ordered_variants[-1][1] - raw_checkpoints = ( - variant.get("checkpointTimes", []) - if isinstance(variant, Mapping) - else [] - ) - checkpoint_candidates = [ - *DEFAULT_COMMON_CHECKPOINT_TIMES, - *( - float(value) - for value in raw_checkpoints - if isinstance(value, (int, float)) - ), - *extra_checkpoints, - stop_time, - ] - checkpoint_times = tuple( - sorted( - { - float(value) - for value in checkpoint_candidates - if 0.0 <= float(value) <= stop_time - } - ) - ) - if isinstance(variant, Mapping) and isinstance( - variant.get("expectedSignalEventTimes"), list - ): - expected_signal_times = tuple( - float(value) for value in variant["expectedSignalEventTimes"] - ) - else: - expected_signal_times = tuple( - sorted( - { - float(value) - for _, candidate in ordered_variants - for value in candidate.get("expectedSignalEventTimes", []) - if isinstance(value, (int, float)) - and float(value) <= stop_time - } - ) - ) - selected.append( - MatrixHorizon( - case_id=case_id, - source_variant_id=source_variant_id, - stop_time=stop_time, - checkpoint_times=checkpoint_times, - expected_signal_event_times=expected_signal_times, - soft_timeout_seconds=_finite_positive( - timeout_variant.get("softTimeoutSeconds"), - field=f"horizon[{request}].softTimeoutSeconds", - ), - hard_timeout_seconds=_finite_positive( - timeout_variant.get("hardTimeoutSeconds"), - field=f"horizon[{request}].hardTimeoutSeconds", - ), - ) - ) - if not selected: - raise RegressionManifestError("At least one matrix horizon is required.") - stop_times = [item.stop_time for item in selected] - if any(right <= left for left, right in zip(stop_times, stop_times[1:])): - raise RegressionManifestError( - "Matrix horizons must have strictly increasing stop times." - ) - return tuple(selected) - - -def _normalise_max_steps(max_steps: Sequence[float]) -> tuple[float, ...]: - values = tuple( - _finite_positive(value, field=f"maxSteps[{index}]") - for index, value in enumerate(max_steps) - ) - if not values: - raise RegressionManifestError("At least one maximum step is required.") - if len(set(values)) != len(values): - raise RegressionManifestError("Maximum steps must be unique.") - return values - - -def _summary(case: Mapping[str, object]) -> Mapping[str, object] | None: - worker = case.get("worker") - if not isinstance(worker, Mapping): - return None - summary = worker.get("summary") - return summary if isinstance(summary, Mapping) else None - - -def _completed(case: Mapping[str, object], stop_time: float) -> bool: - summary = _summary(case) - simulated_until = summary.get("simulatedUntil") if summary is not None else None - return ( - case.get("outcome") == "completed" - and summary is not None - and bool(summary.get("success")) - and summary.get("status") == "completed" - and isinstance(simulated_until, (int, float)) - and math.isclose( - float(simulated_until), - stop_time, - rel_tol=0.0, - abs_tol=max(1.0e-12, 8.0 * math.ulp(max(1.0, abs(stop_time)))), - ) - ) - - -def _checkpoints(summary: Mapping[str, object] | None) -> list[Mapping[str, object]]: - if summary is None: - return [] - contract = summary.get("physicalContract") - raw = ( - contract.get("checkpoints") - if isinstance(contract, Mapping) - else summary.get("checkpoints") - ) - if not isinstance(raw, list): - return [] - return [checkpoint for checkpoint in raw if isinstance(checkpoint, Mapping)] - - -def _event_trace(summary: Mapping[str, object] | None) -> Mapping[str, object]: - if summary is None: - return {} - contract = summary.get("physicalContract") - raw = ( - contract.get("eventTrace") - if isinstance(contract, Mapping) - else summary.get("eventTrace") - ) - return raw if isinstance(raw, Mapping) else {} - - -def _diagnostics(summary: Mapping[str, object] | None) -> Mapping[str, object]: - if summary is None: - return {} - raw = summary.get("diagnostics") - return raw if isinstance(raw, Mapping) else {} - - -def _scaled_residual(summary: Mapping[str, object] | None) -> float | None: - pressure_flow = _diagnostics(summary).get("pressureFlow") - value = ( - pressure_flow.get("maxScaledResidual") - if isinstance(pressure_flow, Mapping) - else None - ) - if isinstance(value, (int, float)) and math.isfinite(float(value)): - return float(value) - return None - - -def _integration_totals(summary: Mapping[str, object] | None) -> dict[str, float]: - integration = _diagnostics(summary).get("integration") - totals = integration.get("totals") if isinstance(integration, Mapping) else None - if not isinstance(totals, Mapping): - return {} - return { - str(key): float(value) - for key, value in totals.items() - if isinstance(value, (int, float)) and math.isfinite(float(value)) - } - - -def _finite_event_times(value: object) -> list[float] | None: - if not isinstance(value, list): - return None - if any( - not isinstance(item, (int, float)) or not math.isfinite(float(item)) - for item in value - ): - return None - return [float(item) for item in value] - - -def _case_observation( - case: Mapping[str, object], - *, - stop_time: float, - expected_projection_count: int | None, - tolerance: MatrixTolerance, - expected_signal_event_times: Sequence[float] | None, -) -> tuple[dict[str, object], tuple[str, ...]]: - summary = _summary(case) - checkpoints = _checkpoints(summary) - checkpoint_details: list[dict[str, object]] = [] - issues: list[str] = [] - for checkpoint in checkpoints: - values = checkpoint.get("stateValues") - state_values = values if isinstance(values, Mapping) else {} - nonfinite = [ - str(key) - for key, value in state_values.items() - if not isinstance(value, (int, float)) or not math.isfinite(float(value)) - ] - detail = { - "requestedTime": checkpoint.get("requestedTime"), - "actualTime": checkpoint.get("actualTime"), - "available": bool(checkpoint.get("available")), - "projectionKeyCount": len(state_values), - "nonfiniteKeyCount": len(nonfinite), - } - checkpoint_details.append(detail) - if not detail["available"]: - issues.append("checkpointUnavailable") - requested_time = checkpoint.get("requestedTime") - actual_time = checkpoint.get("actualTime") - if ( - not isinstance(requested_time, (int, float)) - or not isinstance(actual_time, (int, float)) - or not math.isclose( - float(requested_time), - float(actual_time), - rel_tol=0.0, - abs_tol=tolerance.checkpoint_time_absolute_seconds, - ) - ): - issues.append("checkpointTimeMismatch") - if expected_projection_count is not None and len(state_values) != int( - expected_projection_count - ): - issues.append("projectionCountMismatch") - if nonfinite: - issues.append("nonfiniteProjection") - if not checkpoints: - issues.append("checkpointsUnavailable") - - residual = _scaled_residual(summary) - if tolerance.maximum_scaled_residual is not None and ( - residual is None or residual > tolerance.maximum_scaled_residual - ): - issues.append("scaledResidualExceeded") - - events = _event_trace(summary) - signal_times = _finite_event_times(events.get("signalEventTimes")) - if expected_signal_event_times is not None: - expected = [float(value) for value in expected_signal_event_times] - if signal_times is None or len(signal_times) != len(expected) or any( - not math.isclose( - actual, - wanted, - rel_tol=0.0, - abs_tol=tolerance.signal_event_time_absolute_seconds, - ) - for actual, wanted in zip(signal_times or (), expected) - ): - issues.append("signalEventTraceMismatch") - if events.get("mechanicalTransitionTimesAvailable") is False: - issues.append("mechanicalTransitionTimesUnavailable") - - if not _completed(case, stop_time): - issues.insert(0, "simulationDidNotComplete") - unique_issues = tuple(dict.fromkeys(issues)) - return ( - { - "completed": _completed(case, stop_time), - "checkpointCount": len(checkpoints), - "expectedProjectionCount": expected_projection_count, - "checkpoints": checkpoint_details, - "events": dict(events), - "maximumScaledResidual": residual, - "integrationTotals": _integration_totals(summary), - "passed": not unique_issues, - "issues": list(unique_issues), - }, - unique_issues, - ) - - -def _pair_checkpoints( - left: Mapping[str, object], - right: Mapping[str, object], - *, - tolerance: MatrixTolerance, - strict_prefix_stop: float | None = None, -) -> dict[str, object]: - left_checkpoints = _checkpoints(_summary(left)) - right_checkpoints = _checkpoints(_summary(right)) - paired: list[tuple[Mapping[str, object], Mapping[str, object]]] = [] - for left_checkpoint in left_checkpoints: - left_time = left_checkpoint.get("requestedTime") - if not isinstance(left_time, (int, float)): - continue - if strict_prefix_stop is not None and not ( - float(left_time) - < strict_prefix_stop - tolerance.checkpoint_time_absolute_seconds - ): - continue - match = next( - ( - right_checkpoint - for right_checkpoint in right_checkpoints - if isinstance(right_checkpoint.get("requestedTime"), (int, float)) - and math.isclose( - float(right_checkpoint["requestedTime"]), - float(left_time), - rel_tol=0.0, - abs_tol=tolerance.checkpoint_time_absolute_seconds, - ) - ), - None, - ) - if match is not None: - paired.append((left_checkpoint, match)) - - comparisons: list[dict[str, object]] = [] - mismatch_count = 0 - key_set_mismatch_count = 0 - nonnumeric_count = 0 - maximum_absolute_difference = 0.0 - maximum_relative_difference = 0.0 - for left_checkpoint, right_checkpoint in paired: - left_values = left_checkpoint.get("stateValues") - right_values = right_checkpoint.get("stateValues") - left_mapping = left_values if isinstance(left_values, Mapping) else {} - right_mapping = right_values if isinstance(right_values, Mapping) else {} - left_keys = set(map(str, left_mapping)) - right_keys = set(map(str, right_mapping)) - missing_from_left = sorted(right_keys - left_keys) - missing_from_right = sorted(left_keys - right_keys) - key_set_mismatch_count += len(missing_from_left) + len(missing_from_right) - mismatches: list[dict[str, object]] = [] - for key in sorted(left_keys & right_keys): - left_value = left_mapping.get(key) - right_value = right_mapping.get(key) - if not isinstance(left_value, (int, float)) or not isinstance( - right_value, (int, float) - ) or not math.isfinite(float(left_value)) or not math.isfinite( - float(right_value) - ): - nonnumeric_count += 1 - mismatches.append( - {"key": key, "left": left_value, "right": right_value} - ) - continue - left_numeric = float(left_value) - right_numeric = float(right_value) - absolute = abs(left_numeric - right_numeric) - denominator = max(abs(left_numeric), abs(right_numeric)) - relative = absolute / denominator if denominator else 0.0 - maximum_absolute_difference = max(maximum_absolute_difference, absolute) - maximum_relative_difference = max(maximum_relative_difference, relative) - if not math.isclose( - left_numeric, - right_numeric, - rel_tol=tolerance.state_relative, - abs_tol=tolerance.state_absolute, - ): - mismatches.append( - { - "key": key, - "left": left_numeric, - "right": right_numeric, - "absoluteDifference": absolute, - "relativeDifference": relative, - } - ) - mismatch_count += len(mismatches) - comparisons.append( - { - "requestedTime": left_checkpoint.get("requestedTime"), - "leftProjectionKeyCount": len(left_keys), - "rightProjectionKeyCount": len(right_keys), - "missingFromLeft": missing_from_left, - "missingFromRight": missing_from_right, - "valueMismatchCount": len(mismatches), - "mismatches": mismatches, - } - ) - passed = bool(paired) and not ( - mismatch_count or key_set_mismatch_count or nonnumeric_count - ) - return { - "evaluated": bool(paired), - "passed": passed, - "commonCheckpointCount": len(paired), - "commonCheckpointTimes": [ - pair[0].get("requestedTime") for pair in paired - ], - "strictPrefixStopTime": strict_prefix_stop, - "valueMismatchCount": mismatch_count, - "keySetMismatchCount": key_set_mismatch_count, - "nonnumericValueCount": nonnumeric_count, - "maximumAbsoluteDifference": maximum_absolute_difference, - "maximumRelativeDifference": maximum_relative_difference, - "checkpoints": comparisons, - } - - -def _compare_time_sequences( - left: object, - right: object, - *, - prefix_stop: float, - absolute_tolerance: float, - strict_prefix: bool, -) -> dict[str, object]: - left_times = _finite_event_times(left) - right_times = _finite_event_times(right) - if left_times is None or right_times is None: - return { - "available": False, - "passed": False, - "left": left, - "right": right, - } - if strict_prefix: - include = lambda value: value < prefix_stop - absolute_tolerance - else: - include = lambda value: value <= prefix_stop + absolute_tolerance - left_prefix = [value for value in left_times if include(value)] - right_prefix = [value for value in right_times if include(value)] - passed = len(left_prefix) == len(right_prefix) and all( - math.isclose( - left_value, - right_value, - rel_tol=0.0, - abs_tol=absolute_tolerance, - ) - for left_value, right_value in zip(left_prefix, right_prefix) - ) - return { - "available": True, - "passed": passed, - "left": left_prefix, - "right": right_prefix, - } - - -def _pair_events( - left: Mapping[str, object], - right: Mapping[str, object], - *, - prefix_stop: float, - strict_prefix: bool, - tolerance: MatrixTolerance, -) -> dict[str, object]: - left_trace = _event_trace(_summary(left)) - right_trace = _event_trace(_summary(right)) - signal = _compare_time_sequences( - left_trace.get("signalEventTimes"), - right_trace.get("signalEventTimes"), - prefix_stop=prefix_stop, - absolute_tolerance=tolerance.signal_event_time_absolute_seconds, - strict_prefix=strict_prefix, - ) - mechanical = _compare_time_sequences( - left_trace.get("mechanicalTransitionTimes"), - right_trace.get("mechanicalTransitionTimes"), - prefix_stop=prefix_stop, - absolute_tolerance=tolerance.event_time_absolute_seconds, - strict_prefix=strict_prefix, - ) - availability = ( - left_trace.get("mechanicalTransitionTimesAvailable") is not False - and right_trace.get("mechanicalTransitionTimesAvailable") is not False - ) - return { - "evaluated": bool(left_trace) and bool(right_trace), - "passed": bool(signal["passed"] and mechanical["passed"] and availability), - "prefixStopTime": prefix_stop, - "strictPrefix": strict_prefix, - "signalEventTimes": signal, - "mechanicalTransitionTimes": mechanical, - "mechanicalTransitionTimesAvailable": availability, - "leftStateTransitionCount": left_trace.get("stateTransitionCount"), - "rightStateTransitionCount": right_trace.get("stateTransitionCount"), - } - - -def _ratio(right: float, left: float) -> float | None: - if left == 0.0: - return 1.0 if right == 0.0 else None - return right / left - - -def _pair_diagnostics( - left: Mapping[str, object], - right: Mapping[str, object], - *, - maximum_scaled_residual: float | None, -) -> dict[str, object]: - left_residual = _scaled_residual(_summary(left)) - right_residual = _scaled_residual(_summary(right)) - residuals_pass = maximum_scaled_residual is None or ( - left_residual is not None - and right_residual is not None - and left_residual <= maximum_scaled_residual - and right_residual <= maximum_scaled_residual - ) - left_totals = _integration_totals(_summary(left)) - right_totals = _integration_totals(_summary(right)) - integration: dict[str, dict[str, float | None]] = {} - for key in sorted(set(left_totals) | set(right_totals)): - left_value = left_totals.get(key) - right_value = right_totals.get(key) - integration[key] = { - "left": left_value, - "right": right_value, - "delta": ( - right_value - left_value - if left_value is not None and right_value is not None - else None - ), - "rightOverLeft": ( - _ratio(right_value, left_value) - if left_value is not None and right_value is not None - else None - ), - } - return { - "scaledResidual": { - "left": left_residual, - "right": right_residual, - "limit": maximum_scaled_residual, - "passed": residuals_pass, - }, - "integrationTotals": integration, - } - - -def compare_matrix_cases( - left: Mapping[str, object], - right: Mapping[str, object], - *, - tolerance: MatrixTolerance, -) -> dict[str, object]: - """Compare two completed matrix cells on their common time prefix.""" - - left_stop = float(left["stopTime"]) - right_stop = float(right["stopTime"]) - prefix_stop = min(left_stop, right_stop) - strict_prefix = not math.isclose( - left_stop, - right_stop, - rel_tol=0.0, - abs_tol=tolerance.checkpoint_time_absolute_seconds, - ) - if not _completed(left, left_stop) or not _completed(right, right_stop): - return { - "evaluated": False, - "passed": False, - "reason": "oneOrBothCasesDidNotComplete", - } - state = _pair_checkpoints( - left, - right, - tolerance=tolerance, - strict_prefix_stop=prefix_stop if strict_prefix else None, - ) - events = _pair_events( - left, - right, - prefix_stop=prefix_stop, - strict_prefix=strict_prefix, - tolerance=tolerance, - ) - diagnostics = _pair_diagnostics( - left, - right, - maximum_scaled_residual=tolerance.maximum_scaled_residual, - ) - passed = bool( - state["passed"] - and events["passed"] - and diagnostics["scaledResidual"]["passed"] - ) - return { - "evaluated": True, - "passed": passed, - "commonPrefixStopTime": prefix_stop, - "strictPrefix": strict_prefix, - "stateProjection": state, - "events": events, - "diagnostics": diagnostics, - } - - -def _cell_identity(cell: Mapping[str, object]) -> dict[str, object]: - return { - "matrixCaseId": cell.get("matrixCaseId"), - "horizonCaseId": cell.get("horizonCaseId"), - "stopTime": cell.get("stopTime"), - "maxStep": cell.get("maxStep"), - } - - -def _comparison_record( - kind: str, - left: Mapping[str, object], - right: Mapping[str, object], - tolerance: MatrixTolerance, -) -> dict[str, object]: - return { - "kind": kind, - "left": _cell_identity(left), - "right": _cell_identity(right), - **compare_matrix_cases(left, right, tolerance=tolerance), - } - - -def _build_comparisons( - cases: Sequence[Mapping[str, object]], - *, - horizon_case_ids: Sequence[str], - max_steps: Sequence[float], - tolerance: MatrixTolerance, -) -> dict[str, object]: - by_identity = { - (str(case.get("horizonCaseId")), float(case.get("maxStep"))): case - for case in cases - if case.get("outcome") != "deferred" - and isinstance(case.get("maxStep"), (int, float)) - } - same_horizon: list[dict[str, object]] = [] - for horizon_case_id in horizon_case_ids: - for left_step, right_step in combinations(max_steps, 2): - left = by_identity.get((horizon_case_id, float(left_step))) - right = by_identity.get((horizon_case_id, float(right_step))) - if left is not None and right is not None: - same_horizon.append( - _comparison_record( - "sameHorizonAcrossMaxSteps", left, right, tolerance - ) - ) - same_max_step: list[dict[str, object]] = [] - for max_step in max_steps: - for left_horizon, right_horizon in combinations(horizon_case_ids, 2): - left = by_identity.get((left_horizon, float(max_step))) - right = by_identity.get((right_horizon, float(max_step))) - if left is not None and right is not None: - same_max_step.append( - _comparison_record( - "sameMaxStepAcrossHorizons", left, right, tolerance - ) - ) - evaluated = [*same_horizon, *same_max_step] - comparisons_passed = ( - len(by_identity) == 1 and not evaluated - ) or ( - bool(evaluated) - and all( - bool(item.get("evaluated")) and bool(item.get("passed")) - for item in evaluated - ) - ) - return { - "sameHorizonAcrossMaxSteps": same_horizon, - "sameMaxStepAcrossHorizons": same_max_step, - "evaluatedCount": sum(bool(item.get("evaluated")) for item in evaluated), - "failedCount": sum( - bool(item.get("evaluated")) and not bool(item.get("passed")) - for item in evaluated - ), - "passed": comparisons_passed, - } - - -def _max_step_slug(value: float) -> str: - return format(value, ".12g").replace("-", "m").replace(".", "p") - - -def run_max_step_matrix( - manifest_path: Path | str = DEFAULT_MANIFEST_PATH, - *, - lane: str = "production", - horizon_case_ids: Sequence[str | float] = DEFAULT_HORIZON_CASE_IDS, - max_steps: Sequence[float], - additional_checkpoint_times: Sequence[float] = (), - soft_timeout_seconds: float | None = None, - hard_timeout_seconds: float | None = None, - expected_projection_count: int | None = DEFAULT_EXPECTED_PROJECTION_COUNT, - stop_after_failed_tier: bool = True, - case_executor: MatrixCaseExecutor = execute_regression_case, -) -> dict[str, object]: - """Run a staged, sequential max-step matrix without mutating its source XML.""" - - manifest = load_regression_manifest(manifest_path) - lanes = manifest.get("lanes") - if not isinstance(lanes, Mapping) or lane not in lanes: - raise RegressionManifestError(f"Unknown regression lane {lane!r}.") - lane_config = lanes[lane] - if not isinstance(lane_config, Mapping): - raise RegressionManifestError(f"Lane {lane!r} must be an object.") - selected_horizons = _resolve_horizons( - manifest, - horizon_case_ids, - additional_checkpoint_times=additional_checkpoint_times, - ) - selected_max_steps = _normalise_max_steps(max_steps) - if expected_projection_count is not None and expected_projection_count <= 0: - raise RegressionManifestError("expectedProjectionCount must be positive.") - soft_override = ( - _finite_positive(soft_timeout_seconds, field="softTimeoutSeconds") - if soft_timeout_seconds is not None - else None - ) - hard_override = ( - _finite_positive(hard_timeout_seconds, field="hardTimeoutSeconds") - if hard_timeout_seconds is not None - else None - ) - if soft_override is not None and hard_override is not None and ( - hard_override <= soft_override - ): - raise RegressionManifestError( - "hardTimeoutSeconds must exceed softTimeoutSeconds." - ) - - source_path = Path(str(manifest["_sourcePath"])) - source_payload = source_path.read_bytes() - source_config = source_simulation_config(source_payload) - sampling_mode = lane_config.get("samplingMode", "source") - sample_step = ( - float(source_config["sampleStep"]) - if sampling_mode == "source" - else _finite_positive(lane_config.get("sampleStep"), field="lane.sampleStep") - ) - source_start = float(source_config["tStart"]) - checkpoint_grid_tolerance = max( - 1.0e-12, - 8.0 * math.ulp(max(1.0, abs(source_start))), - 8.0 * math.ulp(max(1.0, abs(sample_step))), - ) - for horizon in selected_horizons: - for checkpoint_time in horizon.checkpoint_times: - if math.isclose( - checkpoint_time, - horizon.stop_time, - rel_tol=0.0, - abs_tol=checkpoint_grid_tolerance, - ): - continue - grid_index = round((checkpoint_time - source_start) / sample_step) - grid_time = source_start + grid_index * sample_step - if not math.isclose( - checkpoint_time, - grid_time, - rel_tol=0.0, - abs_tol=checkpoint_grid_tolerance, - ): - raise RegressionManifestError( - "Matrix checkpoint " - f"{checkpoint_time:.17g} is not represented by the " - f"{sample_step:.17g} s output grid for " - f"{horizon.case_id!r}; use the event trace for off-grid " - "transition times." - ) - instrumentation_mode = str( - lane_config.get("instrumentationMode", "standard") - ) - execution = manifest.get("execution") - if not isinstance(execution, Mapping): - raise RegressionManifestError("Manifest execution must be an object.") - raw_environment = execution.get("environment", {}) - if not isinstance(raw_environment, Mapping) or not all( - isinstance(key, str) and isinstance(value, str) - for key, value in raw_environment.items() - ): - raise RegressionManifestError("execution.environment must map strings to strings.") - environment_overrides = tuple(sorted(raw_environment.items())) - termination_grace = _finite_positive( - execution.get("terminationGraceSeconds", 5.0), - field="execution.terminationGraceSeconds", - ) - expected_sha256 = str(manifest["source"]["sha256"]) # type: ignore[index] - tolerance = _matrix_tolerance(manifest) - - cells: list[dict[str, object]] = [] - tier_decisions: list[dict[str, object]] = [] - later_tiers_enabled = True - for horizon in selected_horizons: - horizon_case_id = horizon.case_id - stop_time = horizon.stop_time - soft_timeout = soft_override or horizon.soft_timeout_seconds - hard_timeout = hard_override or horizon.hard_timeout_seconds - if hard_timeout <= soft_timeout: - raise RegressionManifestError( - f"Hard timeout must exceed soft timeout for {horizon_case_id!r}." - ) - tier_cells: list[dict[str, object]] = [] - if not later_tiers_enabled: - for max_step in selected_max_steps: - cell = { - "matrixCaseId": ( - f"{horizon_case_id}__max_step_{_max_step_slug(max_step)}" - ), - "horizonCaseId": horizon_case_id, - "sourceVariantId": horizon.source_variant_id, - "stopTime": stop_time, - "sampleStep": sample_step, - "maxStep": max_step, - "lane": lane, - "softTimeoutSeconds": soft_timeout, - "hardTimeoutSeconds": hard_timeout, - "outcome": "deferred", - "reason": "previousTierDidNotPass", - "matrixAcceptance": { - "evaluated": False, - "passed": False, - "issues": ["previousTierDidNotPass"], - }, - } - cells.append(cell) - tier_cells.append(cell) - tier_decisions.append( - { - "horizonCaseId": horizon_case_id, - "sourceVariantId": horizon.source_variant_id, - "stopTime": stop_time, - "executed": False, - "passed": False, - "reason": "previousTierDidNotPass", - } - ) - continue - - for max_step in selected_max_steps: - matrix_case_id = ( - f"{horizon_case_id}__max_step_{_max_step_slug(max_step)}" - ) - request = RegressionCaseRequest( - case_id=matrix_case_id, - source_path=source_path, - expected_sha256=expected_sha256, - lane=lane, - stop_time=stop_time, - sample_step=sample_step, - max_step=max_step, - checkpoint_times=tuple( - horizon.checkpoint_times - ), - soft_timeout_seconds=soft_timeout, - hard_timeout_seconds=hard_timeout, - termination_grace_seconds=termination_grace, - instrumentation_mode=instrumentation_mode, - environment_overrides=environment_overrides, - ) - result = case_executor(request) - base_cell = { - "matrixCaseId": matrix_case_id, - "horizonCaseId": horizon_case_id, - "sourceVariantId": horizon.source_variant_id, - "stopTime": stop_time, - "sampleStep": sample_step, - "maxStep": max_step, - "lane": lane, - "softTimeoutSeconds": soft_timeout, - "hardTimeoutSeconds": hard_timeout, - **result, - } - observation, issues = _case_observation( - base_cell, - stop_time=stop_time, - expected_projection_count=expected_projection_count, - tolerance=tolerance, - expected_signal_event_times=horizon.expected_signal_event_times, - ) - cell = { - **base_cell, - "matrixObservation": observation, - "matrixAcceptance": { - "evaluated": True, - "passed": not issues, - "issues": list(issues), - }, - } - cells.append(cell) - tier_cells.append(cell) - tier_passed = all( - bool(cell.get("matrixAcceptance", {}).get("passed")) - for cell in tier_cells - if isinstance(cell.get("matrixAcceptance"), Mapping) - ) - tier_decisions.append( - { - "horizonCaseId": horizon_case_id, - "stopTime": stop_time, - "executed": True, - "passed": tier_passed, - "cellCount": len(tier_cells), - } - ) - if stop_after_failed_tier and not tier_passed: - later_tiers_enabled = False - - comparisons = _build_comparisons( - cells, - horizon_case_ids=[horizon.case_id for horizon in selected_horizons], - max_steps=selected_max_steps, - tolerance=tolerance, - ) - executed_acceptance = [ - cell.get("matrixAcceptance") - for cell in cells - if cell.get("outcome") != "deferred" - and isinstance(cell.get("matrixAcceptance"), Mapping) - ] - deferred_count = sum(cell.get("outcome") == "deferred" for cell in cells) - public_manifest = { - key: value for key, value in manifest.items() if not key.startswith("_") - } - overall_passed = ( - bool(executed_acceptance) - and not deferred_count - and all(bool(item.get("passed")) for item in executed_acceptance) - and bool(comparisons["passed"]) - ) - return { - "schemaVersion": MATRIX_REPORT_SCHEMA_VERSION, - "reportKind": "maxStepHorizonMatrix", - "generatedAt": datetime.now(UTC).isoformat(), - "manifestId": manifest.get("id"), - "manifestPath": str(manifest["_manifestPath"]), - "lane": lane, - "source": { - **dict(manifest["source"]), # type: ignore[arg-type] - "resolvedPath": str(source_path), - "bytes": len(source_payload), - "simulation": source_config, - "mutationPolicy": "readOnly; per-cell overrides are child-memory only", - }, - "configuration": { - "horizons": [ - { - "horizonCaseId": horizon.case_id, - "sourceVariantId": horizon.source_variant_id, - "stopTime": horizon.stop_time, - "checkpointTimes": list(horizon.checkpoint_times), - "defaultSoftTimeoutSeconds": horizon.soft_timeout_seconds, - "defaultHardTimeoutSeconds": horizon.hard_timeout_seconds, - } - for horizon in selected_horizons - ], - "additionalCheckpointTimes": [ - float(value) for value in additional_checkpoint_times - ], - "maxSteps": list(selected_max_steps), - "sampleStep": sample_step, - "samplingMode": sampling_mode, - "softTimeoutOverrideSeconds": soft_override, - "hardTimeoutOverrideSeconds": hard_override, - "expectedProjectionCount": expected_projection_count, - "stopAfterFailedTier": stop_after_failed_tier, - "executionOrder": "horizon-major, max-step serial", - "tolerance": { - "stateRelative": tolerance.state_relative, - "stateAbsolute": tolerance.state_absolute, - "checkpointTimeAbsoluteSeconds": ( - tolerance.checkpoint_time_absolute_seconds - ), - "eventTimeAbsoluteSeconds": tolerance.event_time_absolute_seconds, - "signalEventTimeAbsoluteSeconds": ( - tolerance.signal_event_time_absolute_seconds - ), - "maximumScaledResidual": tolerance.maximum_scaled_residual, - }, - }, - "manifest": public_manifest, - "tierDecisions": tier_decisions, - "cases": cells, - "comparisons": comparisons, - "acceptance": { - "passed": overall_passed, - "executedCellCount": len(executed_acceptance), - "deferredCellCount": deferred_count, - "caseFailureCount": sum( - not bool(item.get("passed")) for item in executed_acceptance - ), - "comparisonFailureCount": comparisons["failedCount"], - }, - } - - -def _parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Run a bounded 1s/5s/10s matrix over multiple maximum integration steps." - ) - ) - parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST_PATH) - parser.add_argument("--lane", default="production") - parser.add_argument( - "--horizon", - action="append", - default=[], - help="Manifest case id or stop time; repeat in sequence order (default: 1s,5s,10s).", - ) - parser.add_argument( - "--checkpoint", - type=float, - action="append", - default=[], - help=( - "Additional checkpoint for every horizon that reaches it; repeat for event-neighbour probes." - ), - ) - parser.add_argument( - "--max-step", - type=float, - action="append", - required=True, - help="Maximum integration step; repeat to form matrix columns.", - ) - parser.add_argument("--soft-timeout", type=float) - parser.add_argument("--hard-timeout", type=float) - parser.add_argument( - "--expected-projection-count", - type=int, - default=DEFAULT_EXPECTED_PROJECTION_COUNT, - ) - parser.add_argument( - "--continue-after-failure", - action="store_true", - help="Run later horizons even when a preceding horizon tier fails.", - ) - parser.add_argument("--output", type=Path) - return parser.parse_args(argv) - - -def main(argv: Sequence[str] | None = None) -> int: - arguments = _parse_arguments(argv) - report = run_max_step_matrix( - arguments.manifest, - lane=arguments.lane, - horizon_case_ids=arguments.horizon or DEFAULT_HORIZON_CASE_IDS, - max_steps=arguments.max_step, - additional_checkpoint_times=arguments.checkpoint, - soft_timeout_seconds=arguments.soft_timeout, - hard_timeout_seconds=arguments.hard_timeout, - expected_projection_count=arguments.expected_projection_count, - stop_after_failed_tier=not arguments.continue_after_failure, - ) - serialized = json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n" - if arguments.output is None: - print(serialized, end="") - else: - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text(serialized, encoding="utf-8") - print(f"Max-step matrix report written to {arguments.output.resolve()}") - if report["acceptance"]["passed"]: - return 0 - if report["acceptance"]["deferredCellCount"] and not report["acceptance"][ - "caseFailureCount" - ]: - return 2 - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/app/simulation/native_codegen/__init__.py b/app/simulation/native_codegen/__init__.py new file mode 100644 index 0000000..05d6f76 --- /dev/null +++ b/app/simulation/native_codegen/__init__.py @@ -0,0 +1 @@ +"""Compilation of supported XML networks to independent native executables.""" diff --git a/app/simulation/native_codegen/__main__.py b/app/simulation/native_codegen/__main__.py new file mode 100644 index 0000000..2fd03ef --- /dev/null +++ b/app/simulation/native_codegen/__main__.py @@ -0,0 +1,82 @@ +"""python -m app.simulation.native_codegen INPUT.xml|json --output-dir DIR""" +from __future__ import annotations + +import argparse +from dataclasses import replace +from hashlib import sha256 +import json +from pathlib import Path +import shutil +import statistics +import time + +from app.main import compile_system_xml_network +from app.simulation.backends import simulation_config +from .build import build_native +from .compiler import compile_native_program +from .input import load_input +from .runner import execute_native + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--method", choices=("RK45", "BDF")) + parser.add_argument("--max-step", type=float) + parser.add_argument("--rtol", type=float) + parser.add_argument("--runs", type=int, default=3) + parser.add_argument("--timeout", type=float, default=300) + parser.add_argument("--solve-only", action="store_true") + args = parser.parse_args() + if args.runs < 1: + parser.error("--runs must be positive") + out = args.output_dir.resolve() + out.mkdir(parents=True, exist_ok=True) + if (out / "summary.json").exists(): + parser.error("Output directory already contains a completed run; choose a new directory.") + started = time.perf_counter() + xml, document = load_input(args.input) + program = compile_native_program(compile_system_xml_network(document)) + config = simulation_config(document.simulation) + for name in ("method", "max_step", "rtol"): + if getattr(args, name) is not None: + config = replace(config, **{name: getattr(args, name)}) + preparation = time.perf_counter()-started + (out / "input.xml").write_bytes(xml) + build = build_native(program) + package = out / "program" + package.mkdir(exist_ok=True) + for name in (*build.manifest["artifacts"], "manifest.json"): + shutil.copy2(build.executable.parent / name, package / name) + (out / "model-manifest.json").write_text(json.dumps(build.manifest, ensure_ascii=False, indent=2), encoding="utf-8") + rows = [] + for i in range(args.runs+1): + data = execute_native(build, config, document.simulation.sample_step, + run_dir=out / ("warmup" if i == 0 else f"run-{i}"), + record_samples=not args.solve_only, timeout=args.timeout) + row = {k: v for k, v in data.items() if k not in ("series", "final", "finalState")} + row["run"] = "warmup" if i == 0 else i + rows.append(row) + print(json.dumps(row, ensure_ascii=False), flush=True) + if not data["success"]: + break + measured = rows[1:] + summary = { + "source": str(args.input.resolve()), "sourceSha256": sha256(args.input.read_bytes()).hexdigest(), + "xmlSha256": sha256(xml).hexdigest(), "executable": str(package / "model.exe"), + "cachedExecutable": str(build.executable), + "buildKey": build.manifest["buildKey"], "buildSeconds": build.seconds, + "preparationSeconds": preparation, "cacheHit": build.cache_hit, + "settings": vars(config), "sampleStep": document.simulation.sample_step, + "recordSamples": not args.solve_only, "runs": rows, + "success": len(measured) == args.runs and all(row["success"] for row in rows), + "medianSolveSeconds": statistics.median(row["solveSeconds"] for row in measured) if measured else None, + } + (out / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({k: v for k, v in summary.items() if k != "runs"}, ensure_ascii=False)) + return 0 if summary["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/simulation/native_codegen/build.py b/app/simulation/native_codegen/build.py new file mode 100644 index 0000000..3c01092 --- /dev/null +++ b/app/simulation/native_codegen/build.py @@ -0,0 +1,119 @@ +"""Reproducible native builds and a checked, model-specific executable cache.""" +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +from .compiler import NativeProgram + +ROOT = Path(__file__).resolve().parents[3] +NATIVE = ROOT / "native" +CACHE = ROOT / "app/data/native-builds" +LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense") + + +@dataclass(frozen=True) +class NativeBuild: + executable: Path + manifest: dict + cache_hit: bool + seconds: float + + +def _hash(path: Path) -> str: + return sha256(path.read_bytes()).hexdigest() + + +def toolchain() -> tuple[str, Path, str]: + compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc") + if not compiler: + raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.") + base = Path(os.environ.get("SUNDIALS_ROOT", str(Path(sys.base_prefix) / "Library"))) + if not (base / "include/cvode/cvode.h").is_file(): + raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.") + version = subprocess.run([compiler, "--version"], capture_output=True, text=True, check=True, timeout=15).stdout.splitlines()[0] + return compiler, base, version + + +def build_native(program: NativeProgram, *, cache_dir: Path | None = None) -> NativeBuild: + start = time.perf_counter() + compiler, sundials, compiler_version = toolchain() + runtime = sorted(NATIVE.rglob("*.c")) + sorted((NATIVE / "include").glob("*.h")) + flags = ["-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math"] + if os.name == "nt": + flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"] + libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES] + dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES] + vc_runtime = sundials / "bin/vcruntime140.dll" + if vc_runtime.is_file(): + dlls.append(vc_runtime) + else: + raise RuntimeError("Native v1 build packaging currently supports Windows x64; Linux packaging is pending.") + sources = {str(p.relative_to(ROOT)): _hash(p) for p in runtime} + sources["native/THIRD_PARTY_NOTICES.txt"] = _hash(NATIVE / "THIRD_PARTY_NOTICES.txt") + dependencies = {str(p.name): _hash(p) for p in libraries + dlls} + # Header hashes include precision/index ABI settings as well as library APIs. + for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol"): + for path in sorted((sundials / "include" / directory).glob("*.h")): + dependencies[f"{directory}/{path.name}"] = _hash(path) + identity = dict(source=program.source, header=program.header, contract=program.manifest(), sources=sources, + dependencies=dependencies, compiler=compiler_version, flags=flags, + platform=sys.platform, abi=1) + signature = sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() + cache = (cache_dir or CACHE).resolve() + target = cache / signature + manifest_path = target / "manifest.json" + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if all((target / name).is_file() and _hash(target / name) == digest + for name, digest in manifest["artifacts"].items()): + return NativeBuild(target / "model.exe", manifest, True, time.perf_counter()-start) + raise RuntimeError(f"Native cache integrity check failed: {target}") + cache.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix="building-", dir=cache)) + (stage / "model.c").write_text(program.source, encoding="utf-8") + (stage / "model.h").write_text(program.header, encoding="utf-8") + command = [compiler, *flags, "-I", str(stage), "-I", str(NATIVE / "include"), + "-I", str(sundials / "include"), str(stage / "model.c"), + *[str(p) for p in runtime if p.suffix == ".c"], + *map(str, libraries), "-lm", "-o", str(stage / "model.exe")] + result = subprocess.run(command, capture_output=True, text=True, timeout=120) + (stage / "build.log").write_text(result.stdout + result.stderr, encoding="utf-8") + if result.returncode: + raise RuntimeError(f"Native compilation failed; see {stage / 'build.log'}: {result.stderr[-3000:]}") + for library in dlls: + shutil.copy2(library, stage / library.name) + shutil.copy2(NATIVE / "THIRD_PARTY_NOTICES.txt", stage / "THIRD_PARTY_NOTICES.txt") + manifest = { + **program.manifest(), "buildKey": signature, "compiler": compiler_version, + "compilerFlags": flags, "sourceHashes": sources, "dependencyHashes": dependencies, + "artifacts": {p.name: _hash(p) for p in stage.iterdir() if p.name != "build.log"}, + } + (stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + try: + stage.rename(target) + except OSError: + # A concurrent compiler may have published the identical cache first. + if not manifest_path.is_file(): + raise + published = json.loads(manifest_path.read_text(encoding="utf-8")) + # PE linker timestamps can differ between concurrent equivalent builds. + # Validate the winning build against its own hashes and our identity. + if published.get("buildKey") != signature or not all( + (target / name).is_file() and _hash(target / name) == digest + for name, digest in published["artifacts"].items() + ): + raise RuntimeError("Concurrent native build did not produce the expected artifacts.") + manifest = published + if stage.resolve().parent != cache: + raise RuntimeError("Unexpected native build staging directory.") + shutil.rmtree(stage) + return NativeBuild(target / "model.exe", manifest, False, time.perf_counter()-start) diff --git a/app/simulation/native_codegen/compiler.py b/app/simulation/native_codegen/compiler.py new file mode 100644 index 0000000..6b7e577 --- /dev/null +++ b/app/simulation/native_codegen/compiler.py @@ -0,0 +1,388 @@ +"""Lower reviewed component contracts to a static C evaluation schedule. + +The compact storage-anchored schedule and the extended catalog schedule both +produce standalone C numerics, without Python callbacks or numerical fallback. +""" +from __future__ import annotations + +from dataclasses import dataclass +import json +from math import isfinite + +from app.simulation.core.metadata import ResultVariableMetadata +from app.simulation.systems.network import SimulationNetwork +from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS + + +class NativeCapabilityError(ValueError): + """The complete model cannot be represented by this native backend.""" + + +_STORAGE_ANCHORED_TYPES = frozenset( + "amesim_" + name for name in ( + "pnch023", "pnch012", "pnvo001", "pnpl01", "step0", "ud00", + "forc", "pnrp17", "mecmas21", "f000", "lstp00a", + ) +) + + +@dataclass(frozen=True) +class NativeProgram: + source: str + header: str + state_keys: tuple[str, ...] + variables: tuple[ResultVariableMetadata, ...] + component_types: tuple[str, ...] + + def manifest(self) -> dict: + return { + "abiVersion": 1, "stateKeys": self.state_keys, + "variables": [v.as_dict() for v in self.variables], + "componentTypes": self.component_types, + "componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types}, + "jacobianPolicy": "CVODE default; no custom Jacobian", + } + + +class _Groups: + def __init__(self, items): + self.parent = {x: x for x in items} + + def find(self, x): + if self.parent[x] != x: + self.parent[x] = self.find(self.parent[x]) + return self.parent[x] + + def union(self, a, b): + self.parent[self.find(b)] = self.find(a) + + +def _number(value): + value = float(value) + if not isfinite(value): + raise NativeCapabilityError("Native constants must be finite.") + return repr(value) + + +def compile_native_program(network: SimulationNetwork) -> NativeProgram: + from .extended import catalog_contracts + contracts = catalog_contracts() + for component in network.components.values(): + if type(component) is not contracts.get(component.model_type): + raise NativeCapabilityError(f'{component.name}: no native contract for {component.model_type}') + if component.MODEL_VERSION != SUPPORTED_VERSIONS.get(component.model_type): + raise NativeCapabilityError(f'{component.name}: native kernel version does not match the component contract') + # Preserve the compact, verified schedule for its supported topology. + # Both paths emit C; this never falls back to Python numerical execution. + try: + return _compile_storage_anchored_program(network) + except NativeCapabilityError: + from .extended import compile_extended_program + return compile_extended_program(network) + + +def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgram: + components = list(network.components.values()) + if not components: + raise NativeCapabilityError("Native simulation requires a dynamic model.") + for c in components: + if c.model_type not in _STORAGE_ANCHORED_TYPES: + raise NativeCapabilityError(f"{c.name}: unsupported native component {c.model_type}.") + if c.MODEL_VERSION != SUPPORTED_VERSIONS[c.model_type]: + raise NativeCapabilityError(f"{c.name}: native kernel version does not match the component contract.") + if c.model_type == "amesim_pnch023" and c.kth*c.sth != 0: + raise NativeCapabilityError(f"{c.name}: native v1 supports adiabatic PNCH023 only.") + medium = getattr(c, "medium", None) + if medium is not None and ( + getattr(medium, "SUBSTANCE_ID", None), + getattr(medium, "PROPERTY_METHOD_ID", None), + ) != ("helium", "peng_robinson"): + raise NativeCapabilityError(f"{c.name}: native v1 requires helium / Peng-Robinson.") + if c.model_type == "amesim_mecmas21": + if int(c.stoptype) not in (1, 4) or any( + getattr(c, key) != 0 for key in ("fcoul", "fstick", "rvisc", "wind", "theta") + ): + raise NativeCapabilityError(f"{c.name}: native v1 supports friction-free masses with stoptype 1 or 4.") + if c.model_type == "amesim_lstp00a" and int(c.stiffmode) != 1: + raise NativeCapabilityError(f"{c.name}: native v1 requires explicit contact stiffness.") + ports = { + (c.name, p.name): p for c in components for p in c.active_port_definitions + } + adjacent = {} + groups = _Groups(ports) + for edge in network.connections: + a, b = (p.key for p in edge.endpoints) + # Signal fan-out is allowed; physical ports have one external connection. + if edge.kind == "physical": + if a in adjacent or b in adjacent: + raise NativeCapabilityError("Native physical ports require one connection each.") + adjacent[a], adjacent[b] = b, a + groups.union(a, b) + else: + source, target = (a, b) if ports[a].nominal_role == "output" else (b, a) + adjacent[target] = source + for c in components: + for name in c.required_connection_ports: + if (c.name, name) not in adjacent: + raise NativeCapabilityError(f"{c.name}.{name}: unconnected physical port.") + names = [p.name for p in c.active_port_definitions if p.domain == "pneumatic"] + if c.model_type in ("amesim_pnch023", "amesim_pnch012"): + for name in names[1:]: + groups.union((c.name, names[0]), (c.name, name)) + if c.model_type == "amesim_mecmas21": + groups.union((c.name, "port_1"), (c.name, "port_2")) + if c.model_type == "amesim_pnrp17": + groups.union((c.name, "port_2"), (c.name, "port_5")) + groups.union((c.name, "port_3"), (c.name, "port_4")) + + chambers = [c for c in components if c.model_type in ("amesim_pnch023", "amesim_pnch012")] + masses = [c for c in components if c.model_type == "amesim_mecmas21"] + chamber_by_group, mass_by_group = {}, {} + for items, mapping in ((chambers, chamber_by_group), (masses, mass_by_group)): + for c in items: + root = groups.find((c.name, "port_1")) + if root in mapping: + raise NativeCapabilityError(f"{c.name}: coupled storage/mass reduction is outside native v1.") + mapping[root] = c + for ep, port in ports.items(): + mapping = chamber_by_group if port.domain == "pneumatic" else mass_by_group + if port.kind == "physical" and groups.find(ep) not in mapping: + raise NativeCapabilityError(f"{ep}: no unique storage/mass anchor; native v1 cannot close this block.") + + variables = tuple(v for c in components for v in c.result_variable_metadata()) + slots = {v.key: i for i, v in enumerate(variables)} + assigned = set() + lines, init, declarations = [], [], [] + state_keys, state_index = [], {} + for c in components: + fields = ("m", "U") if c in chambers else (("v", "x") if c in masses else ()) + for field in fields: + key = f"{c.name}.{field}" + state_index[key] = len(state_keys) + state_keys.append(key) + if not state_keys: + raise NativeCapabilityError("Native v1 requires continuous states.") + if len(state_keys) > 256 or len(variables) > 8192: + raise NativeCapabilityError("Native v1 supports at most 256 states and 8192 outputs per model.") + + def w(key): + return f"w[{slots[key]}]" + + def key(c, field): + return f"{c.name}.{field}" + + def get(c, field): + return w(key(c, field)) + + def put(c, field, expression): + k = key(c, field) + lines.append(f"{w(k)} = {expression};") + assigned.add(k) + + def si(c, field): + return state_index[key(c, field)] + + def mass_at(c, port): + return mass_by_group[groups.find((c.name, port))] + + def chamber_at(c, port): + return chamber_by_group[groups.find((c.name, port))] + + for c in masses: + init.extend([f"y[{si(c, 'v')}] = {_number(c.v0)};", f"y[{si(c, 'x')}] = {_number(c.x0)};"]) + for field in ("v", "x"): + put(c, field, f"y[{si(c, field)}]") + for (cid, pname), port in ports.items(): + if port.domain == "mechanical": + m = mass_by_group[groups.find((cid, pname))] + for field in ("v", "x"): + k = f"{cid}.{pname}.{field}" + lines.append(f"{w(k)} = y[{si(m, field)}];") + assigned.add(k) + + signal_specs = [] + for c in components: + if c.model_type == "amesim_step0": + put(c, "y", f"t < {_number(c.time)} ? {_number(c.initial)} : {_number(c.final)}") + signal_specs.append(("step", c)) + elif c.model_type == "amesim_ud00": + index = len(signal_specs) + declarations.append(f"static const double signal_{index}[24] = {{" + ",".join( + _number(v) for values in (c.starts, c.ends, c.durations) for v in values + ) + "};") + put(c, "y", f"native_signal(t, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index})") + signal_specs.append((index, c)) + else: + continue + put(c, "out.signal", get(c, "y")) + for c in components: + for port in c.active_port_definitions: + if port.kind == "signal" and port.nominal_role == "input": + ep = (c.name, port.name) + if ep not in adjacent: + # PNVO's explicit unconnected opening is a supported default. + if c.model_type == "amesim_pnvo001": + put(c, port.name + ".signal", _number(c.opening0)) + continue + raise NativeCapabilityError(f"{ep}: missing signal input.") + source = adjacent[ep] + source_key = f"{source[0]}.{source[1]}.signal" + if source_key not in assigned: + raise NativeCapabilityError(f"{ep}: unsupported signal dependency.") + put(c, port.name + ".signal", w(source_key)) + + pistons = [c for c in components if c.model_type == "amesim_pnrp17"] + for c in pistons: + put(c, "length", f"{_number(c.x0)} + {get(c, 'port_5.x')} - {get(c, 'port_4.x')}") + put(c, "volume", f"{_number(c.effective_area)} * {get(c, 'length')}") + put(c, "volume_flow", f"{_number(c.effective_area)} * ({get(c, 'port_5.v')} - {get(c, 'port_4.v')})") + if chamber_at(c, "port_1").model_type != "amesim_pnch012": + raise NativeCapabilityError(f"{c.name}: moving volume requires PNCH012.") + volume_expressions = {} + for gi, c in enumerate(chambers): + connected = [p for p in pistons if chamber_at(p, "port_1") is c] + base = c.cvol if c.model_type == "amesim_pnch023" else c.cvol0 + sum(c.external_volumes.values()) + volume = _number(base) + rate = "0.0" + if c.model_type == "amesim_pnch012": + volume += "".join(" + " + get(p, "volume") for p in connected) + put(c, "vol", f"fmax({_number(c.cvol0 / 100)}, {volume})") + rate = _number(sum(c.external_volume_rates.values())) + "".join(" + " + get(p, "volume_flow") for p in connected) + put(c, "dvol", f"{get(c, 'vol')} <= {_number(c.cvol0 / 100)} ? 0.0 : ({rate})") + volume, rate = get(c, "vol"), get(c, "dvol") + volume_expressions[c.name] = (volume, rate) + # Match the Python constructor: m/U use configured storage volume; + # connected moving volumes subsequently change recovered p/T. + initial_volume = max(base, c.cvol0 / 100) if c.model_type == "amesim_pnch012" else base + init.append(f"if (!native_gas_init({_number(c.p0)}, {_number(c.T0)}, {_number(initial_volume)}, &y[{si(c, 'm')}])) return 0;") + lines.append(f"NativeGas gas_{gi};") + lines.append(f"if (!native_gas(y[{si(c, 'm')}], y[{si(c, 'U')}], {volume}, &gas_{gi})) return 0;") + for field in ("m", "U"): + put(c, field, f"y[{si(c, field)}]") + for field in ("p", "T", "rho", "u", "h"): + put(c, field, f"gas_{gi}.{field}") + for (cid, pname), port in ports.items(): + if port.domain == "pneumatic": + c = chamber_by_group[groups.find((cid, pname))] + for field, expr in (("p", get(c, "p")), ("h_outflow", get(c, "h")), ("m_flow", "0.0")): + k = f"{cid}.{pname}.{field}" + lines.append(f"{w(k)} = {expr};") + assigned.add(k) + for c in components: + if c.model_type != "amesim_pnvo001": + continue + a, b = chamber_at(c, "port_2"), chamber_at(c, "port_3") + put(c, "xv", f"fmax(0.0, fmin(1.0, {get(c, 'res.signal')}))") + lines.append(f"if (!native_orifice({get(a, 'p')}, {get(b, 'p')}, {get(a, 'h')}, {get(b, 'h')}, {_number(c.effective_cq * c.maximum_area)}, {get(c, 'xv')}, &{get(c, 'port_2.m_flow')}, &{get(c, 'cm')}, &{get(c, 'gasvel')})) return 0;") + assigned.update((key(c, "cm"), key(c, "gasvel"))) + put(c, "port_3.m_flow", f"-{get(c, 'port_2.m_flow')}") + put(c, "port_2.h_outflow", get(b, "h")) + put(c, "port_3.h_outflow", get(a, "h")) + for pname in ("port_2", "port_3"): + other = adjacent[c.name, pname] + # A resistance-to-resistance stream path requires a fuller IR. + if network.components[other[0]] not in chambers: + raise NativeCapabilityError(f"{c.name}: native v1 requires valve ports directly connected to storage.") + lines.append(f"{w(f'{other[0]}.{other[1]}.m_flow')} = -{get(c, pname + '.m_flow')};") + + force_known = set() + def force(c, port, expr): + put(c, port + ".f", expr) + force_known.add((c.name, port)) + equations = [] + for c in components: + if c.model_type == "amesim_forc": + put(c, "force", f"{_number(c.direction)} * {get(c, 'res.signal')}") + force(c, "port_2", f"-{get(c, 'force')}") + elif c.model_type == "amesim_f000": + force(c, "port_1", "0.0") + elif c.model_type == "amesim_lstp00a": + put(c, "gap", f"{_number(c.gap0)} + ({get(c, 'port_2.x')} - {get(c, 'port_1.x')})") + put(c, "penetration", f"fmax(-{get(c, 'gap')}, 0.0)") + put(c, "force", f"native_contact({get(c, 'penetration')}, {get(c, 'port_1.v')} - {get(c, 'port_2.v')}, {_number(c.kcont)}, {_number(c.rcont)}, {_number(c.Pdis)}, {int(c.discContactOption)})") + force(c, "port_1", get(c, "force")) + force(c, "port_2", f"-{get(c, 'force')}") + elif c.model_type == "amesim_pnrp17": + put(c, "pressure_force", f"({get(c, 'port_1.p')} - 101300.0) * {_number(c.effective_area)}") + equations.extend([ + ((c.name, "port_2"), (c.name, "port_5"), f"-{get(c, 'pressure_force')}"), + ((c.name, "port_3"), (c.name, "port_4"), get(c, "pressure_force")), + ]) + for edge in network.connections: + if edge.domain == "mechanical": + equations.append((*[p.key for p in edge.endpoints], "0.0")) + pending = equations + while pending: + remaining = [] + for a, b, total in pending: + if a in force_known and b in force_known: + raise NativeCapabilityError("Overconstrained native force balance.") + if a not in force_known and b not in force_known: + remaining.append((a, b, total)) + continue + target, source = (b, a) if a in force_known else (a, b) + c = network.components[target[0]] + force(c, target[1], f"({total}) - {w(f'{source[0]}.{source[1]}.f')}") + if len(remaining) == len(pending): + raise NativeCapabilityError("Native v1 cannot resolve this mechanical force loop.") + pending = remaining + for c in masses: + expression = f"({get(c, 'port_1.f')} + {get(c, 'port_2.f')}) / {_number(c.mass)}" + put(c, "a", expression) + lines.append(f"dy[{si(c, 'v')}] = {get(c, 'a')}; dy[{si(c, 'x')}] = {get(c, 'v')};") + if int(c.stoptype) == 1: + lines.append(f"native_stop_motion({get(c, 'x')}, {get(c, 'v')}, {_number(c.xmin)}, {_number(c.xmax)}, &dy[{si(c, 'v')}], &dy[{si(c, 'x')}]);") + put(c, "a", f"dy[{si(c, 'v')}]") + for field in ("Fvisc", "Ffric", "Fmin", "Fmax"): + put(c, field, "0.0") + for c in chambers: + mass_terms, energy_terms = [], [] + for port in c.active_port_definitions: + q = get(c, port.name + ".m_flow") + other = adjacent[c.name, port.name] + inlet_h = w(f"{other[0]}.{other[1]}.h_outflow") + mass_terms.append(q) + energy_terms.append(f"{q} * ({q} > 0.0 ? {inlet_h} : {get(c, 'h')})") + volume, rate = volume_expressions[c.name] + energy_terms.extend([f"{_number(c.kth*c.sth)} * ({_number(c.extemp)} - {get(c, 'T')})", f"-{get(c, 'p')} * ({rate})"]) + lines.extend([f"dy[{si(c, 'm')}] = " + " + ".join(mass_terms) + ";", f"dy[{si(c, 'U')}] = " + " + ".join(energy_terms) + ";"]) + missing = set(slots) - assigned + if missing: + raise NativeCapabilityError(f"Native output coverage is incomplete: {sorted(missing)}") + + stops = [(si(c, "v"), c.xmin, c.xmax) for c in masses if int(c.stoptype) == 1] + stop_c = ",".join(f"{{{v},{_number(lo)},{_number(hi)},0,0,0,0}}" for v, lo, hi in stops) or "{0,0,0,0,0,0,0}" + next_event = [] + for index, c in signal_specs: + if index == "step": + next_event.append(f"if (t < {_number(c.time)}) result = fmin(result, {_number(c.time)});") + else: + next_event.append(f"result = fmin(result, native_signal_break(t, end, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index}));") + source = '\n'.join([ + '#include "model.h"', '#include ', *declarations, + f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};", + f"const double model_atol[NSTATES] = {{{','.join('1e-12' if k.rsplit('.',1)[1] in ('v','x') else '1e-8' for k in state_keys)}}};", + "const char *const model_output_keys[NOUTPUTS] = {" + ",".join(json.dumps(v.key, ensure_ascii=True) for v in variables) + "};", + "int model_init(double *y) {", *init, "return 1; }", + "int model_eval(double t, const double *y, double *dy, double *w) {", "(void)t;", *lines, + "for (int i=0;i 1e-14), None) + if found is None and key in free_flows: + # Ideal coupled pipe compliances have a redundant internal flow. + # Their total m/U derivative is distributed by physical volume. + index = len(equations) + equations.append(({key: 1.0}, '0.0')) + rows.append([{key: 1.0}, {index: 1.0}]) + found = len(rows)-1 + if found is None: + raise NativeCapabilityError(f'Underdetermined native connection constraint: {key}') + j = len(pivots) + rows[j], rows[found] = rows[found], rows[j] + a, b = rows[j] + pivot = a[key] + rows[j] = [{k: v/pivot for k, v in a.items()}, {k: v/pivot for k, v in b.items()}] + for i, (a, b) in enumerate(rows): + if i == j: + continue + scale = a.get(key, 0) + if not scale: + continue + for target, source in zip((a, b), rows[j]): + for k, v in source.items(): + value = target.get(k, 0) - scale*v + if abs(value) < 1e-14: + target.pop(k, None) + else: + target[k] = value + pivots.append(key) + lines = [f'double b{i} = {expr};' for i, (_, expr) in enumerate(equations) + if any(i in rows[j][1] for j in range(len(pivots)))] + for j, key in enumerate(pivots): + expr = ' + '.join(f'({num(v)})*b{i}' for i, v in rows[j][1].items()) or '0.0' + lines.append(f'{key} = {expr};') + return lines + + +def compile_extended_program(network): + components = list(network.components.values()) + contracts = catalog_contracts() + for c in components: + if type(c) is not contracts.get(c.model_type): + raise NativeCapabilityError(f'{c.name}: no native contract for {c.model_type} / {type(c).__name__}') + if c.MODEL_VERSION != SUPPORTED_VERSIONS.get(c.model_type): + raise NativeCapabilityError(f'{c.name}: native kernel version does not match the component contract') + variables = tuple(v for c in components for v in c.result_variable_metadata()) + if not variables: + raise NativeCapabilityError('Simulation requires a runtime component; medium definitions alone have no outputs') + slots = {v.key: i for i, v in enumerate(variables)} + ports = {(c.name, p.name): p for c in components for p in c.active_port_definitions} + groups = _Groups(ports) + adjacent = {} + for edge in network.connections: + a, b = (p.key for p in edge.endpoints) + if edge.kind == 'physical': + adjacent[a], adjacent[b] = b, a + groups.union(a, b) + else: + source, target = (a, b) if ports[a].nominal_role == 'output' else (b, a) + adjacent[target] = source + for c in components: + for port in c.required_connection_ports: + if (c.name, port) not in adjacent: + raise NativeCapabilityError(f'{c.name}.{port}: unconnected required port') + names = [p.name for p in c.active_port_definitions] + if c.model_type in NODES | {'amesim_pnch023', 'amesim_pnch012', 'amesim_mecmas21', 'amesim_lmechn1'} or (c.model_type=='pipe' and c.lambda_darcy==0): + for p in names[1:]: + groups.union((c.name, names[0]), (c.name, p)) + if c.model_type == 'amesim_pnrp17': + for a, b in [('port_2', 'port_5'), ('port_3', 'port_4')]: + groups.union((c.name, a), (c.name, b)) + + state_keys, initial, gas_initializers = [], [], [] + states = {} + def add_states(c, fields, values): + for field, value in zip(fields, values): + states[c.name, field] = len(state_keys) + state_keys.append(f'{c.name}.{field}') + initial.append(float(value)) + mass_groups = {} + for c in components: + if c.model_type in GAS_TYPES: + fields = ('m1', 'U1', 'm2', 'U2') if c.model_type == 'amesim_pnl0003' else ('m', 'U') + add_states(c, fields, [0.0]*len(fields)) + elif c.model_type == 'amesim_mecmas21': + root = groups.find((c.name, 'port_1')) + mass_groups.setdefault(root, []).append(c) + if len(mass_groups[root]) == 1: + add_states(c, ('v', 'x'), (c.v0, c.x0)) + else: + ref = mass_groups[root][0] + if abs(c.v0-ref.v0)>1e-10*max(abs(c.v0),1) or abs(c.x0-ref.x0)>1e-10*max(abs(c.x0),1): + raise NativeCapabilityError('Rigidly connected masses require consistent initial x/v') + for field in ('v', 'x'): + states[c.name, field] = states[ref.name, field] + if len(state_keys)>1024 or len(variables)>16384: + raise NativeCapabilityError('Native model exceeds the 1024-state / 16384-output resource limit') + # Algebraic models use an internal constant state; the public state map stays empty. + nstates = max(len(state_keys), 1) + if not initial: + initial = [0.0] + lines, declarations, breaks = [], [], [] + assigned = set() + def w(c, field): + name = c if isinstance(c, str) else c.name + return f'w[{slots[name+"."+field]}]' + def put(c, field, expr, dest=None): + (lines if dest is None else dest).append(f'{w(c,field)} = {expr};') + assigned.add((c if isinstance(c,str) else c.name)+'.'+field) + def y(c, field): + return f'y[{states[c.name,field]}]' + def ep(c, port): + return c.name, port + def pnames(c): + return [p.name for p in c.active_port_definitions if p.domain == 'pneumatic'] + def mnames(c): + return [p.name for p in c.active_port_definitions if p.domain == 'mechanical'] + for c in components: + if c.model_type == 'amesim_mecmas21': + for f in ('v', 'x'): + put(c, f, y(c, f)) + for name in mnames(c): + root = groups.find(ep(c, name)) + if root not in mass_groups: + raise NativeCapabilityError(f'{c.name}.{name}: mechanical group has no inertia anchor') + mass = mass_groups[root][0] + for f in ('v', 'x'): + put(c, name+'.'+f, y(mass, f)) + if c.model_type == 'amesim_step0': + put(c, 'y', f't < {num(c.time)} ? {num(c.initial)} : {num(c.final)}') + breaks.append(f'if(t < {num(c.time)}) result=fmin(result,{num(c.time)});') + elif c.model_type == 'amesim_ud00': + ident = 'signal_'+str(len(declarations)) + declarations.append(f'static const double {ident}[24] = {{'+','.join(num(v) for vs in (c.starts,c.ends,c.durations) for v in vs)+'};') + args=f'{num(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, {ident}' + put(c, 'y', f'native_signal(t, {args})') + breaks.append(f'result=fmin(result,native_signal_break(t,end,{args}));') + else: + continue + put(c, 'out.signal', w(c, 'y')) + for c in components: + for port in c.active_port_definitions: + if port.kind == 'signal' and port.nominal_role == 'input': + target = adjacent.get(ep(c, port.name)) + if target: + expr = w(target[0], target[1]+'.signal') + elif c.model_type == 'amesim_pnvo001': + expr = num(c.opening0) + else: + raise NativeCapabilityError(f'{c.name}.{port.name}: signal input missing') + put(c, port.name+'.signal', expr) + + media = {} + def medium(c): + m = c.medium + key = (getattr(m, 'SUBSTANCE_ID', None), getattr(m, 'PROPERTY_METHOD_ID', None), + m.R_gas, m.cp_ref, m.T_ref, m.cp_slope, m.viscosity_ref, m.viscosity_T_ref, m.sutherland_constant) + if key not in media: + if key[1] not in (None, 'ideal_gas', 'peng_robinson') or (key[1]=='peng_robinson' and key[0]!='helium'): + raise NativeCapabilityError(f'{c.name}: unsupported medium contract {key[:2]}') + ident = f'medium_{len(media)}' + declarations.append(f'static const NativeMedium {ident} = {{'+str(int(key[1]=='peng_robinson'))+','+','.join(num(v) for v in key[2:])+'};') + media[key]=ident + return '&'+media[key] + + pistons = [c for c in components if c.model_type == 'amesim_pnrp17'] + for c in pistons: + put(c, 'length', f'{num(c.x0)}+{w(c,"port_5.x")}-{w(c,"port_4.x")}') + put(c, 'volume', f'{num(c.effective_area)}*{w(c,"length")}') + put(c, 'volume_flow', f'{num(c.effective_area)}*({w(c,"port_5.v")}-{w(c,"port_4.v")})') + + pneu = [endpoint for endpoint, port in ports.items() if port.domain == 'pneumatic'] + pi = {endpoint: i for i, endpoint in enumerate(pneu)} + pgroups = list(dict.fromkeys(groups.find(endpoint) for endpoint in pneu)) + pgi = {root: i for i, root in enumerate(pgroups)} + def p(c, name): return f'p[{pgi[groups.find(ep(c,name))]}]' + def q(c, name): return f'q[{pi[ep(c,name)]}]' + def h(c, name): return f'h[{pi[ep(c,name)]}]' + def hin(c, name, temperature=False): + other = adjacent[ep(c,name)] + obj = network.components[other[0]] + if temperature and obj.model_type in NODES-{'tee'}: + other = adjacent[(obj.name, 'port_2')] + return f'h[{pi[other]}]' + gases, anchor, port_gas, volume_rate = {}, {}, {}, {} + anchor_partitions = {} + gas_count = 0 + for c in components: + if c.model_type not in GAS_TYPES: + continue + kind = c.model_type + if kind == 'amesim_pnch023': + volume, rate = num(c.cvol), '0.0' + elif kind == 'amesim_pnch012': + attached = [d for d in pistons if adjacent[ep(d,'port_1')][0] == c.name] + volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached) + put(c, 'vol', f'fmax({num(c.cvol0/100)}, {volume})') + rate = num(sum(c.external_volume_rates.values()))+''.join('+'+w(d,'volume_flow') for d in attached) + put(c, 'dvol', f'{w(c,"vol")} <= {num(c.cvol0/100)} ? 0.0 : ({rate})') + volume, rate = w(c,'vol'), w(c,'dvol') + elif kind in ('cylinder', 'tank'): + volume, rate = num(c.V), '0.0' + else: + volume, rate = num(c.volume), '0.0' + volume_rate[c.name] = rate + halves = (1,2) if kind == 'amesim_pnl0003' else (0,) + for half in halves: + suffix = str(half) if half else '' + gas = f'g[{gas_count}]';gas_count += 1 + gases[c.name,half] = gas + V = num(c.compliance_volume) if half else volume + if half: + p0,T0=c.parameter_values[f'p{half}_0'],c.parameter_values[f'T{half}_0'] + initial_volume=c.compliance_volume + else: + p0,T0=c.parameter_values['p0'],c.parameter_values['T0'] + initial_volume=(max(c.cvol0+sum(c.external_volumes.values()),c.cvol0/100) + if kind=='amesim_pnch012' else + c.cvol if kind=='amesim_pnch023' else c.V if kind in ('cylinder','tank') else c.volume) + gas_initializers.append(f'if(!native_medium_init({medium(c)},{num(p0)},{num(T0)},{num(initial_volume)},{int(kind in ("cylinder","tank"))},&y[{states[c.name,"m"+suffix]}])) return 0;') + lines.append(f'if(!native_medium_gas({medium(c)}, {y(c,"m"+suffix)}, {y(c,"U"+suffix)}, {V}, &{gas})) return 0;') + for field in ('m', 'U'): + put(c, field+suffix, y(c, field+suffix)) + for field in ('p','T','rho','u','h'): + put(c, field+suffix, gas+'.'+field) + anchored = pnames(c) + if kind == 'amesim_pnl0001': anchored=['port_2'] + if kind == 'amesim_pnl0002': anchored=[] + if half: anchored=['port_'+suffix] + for name in anchored: + root=groups.find(ep(c,name)) + anchor[root]=gas + partition=(c.name,half) + anchor_partitions.setdefault(root, {})[partition]=(c, float(c.compliance_volume if half else c.volume) if kind in ('amesim_pnl0001','amesim_pnl0003') else None) + for name in (['port_'+suffix] if half else pnames(c)): + port_gas[ep(c,name)]=gas + coupled=[] + project=[] + for root,partitions in anchor_partitions.items(): + if len(partitions)<2: continue + if any(volume is None for _,volume in partitions.values()) or len({id(c.medium) for c,_ in partitions.values()})!=1: + raise NativeCapabilityError('Direct gas-storage coupling requires compatible fixed pipe compliances; insert a resistance between independent chambers') + offsets=[];volumes=[] + for (name,half),(c,volume) in partitions.items(): + offsets.append(states[name,'m'+(str(half) if half else '')]);volumes.append(volume) + for prefix in ('p','T'): + values=[c.parameter_values[f'{prefix}{half}_0'] if half else getattr(c,prefix+'0') + for (_,half),(c,_) in partitions.items()] + if max(values)-min(values)>1e-9*max(1,*map(abs,values)): + raise NativeCapabilityError('Ideally coupled pipe compliances require consistent initial pressure and temperature') + total=sum(volumes) + project.append('{ double mass='+ '+'.join(f'y[{i}]' for i in offsets)+',energy='+ '+'.join(f'y[{i+1}]' for i in offsets)+';') + for i,volume in zip(offsets,volumes): + project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=energy*{num(volume/total)};'] + project.append('}') + coupled.append((root,offsets,volumes)) + for root, gas in anchor.items(): + lines.append(f'p[{pgi[root]}]={gas}.p;') + default_h = next(iter(gases.values()))+'.h' if gases else '0.0' + for endpoint, idx in pi.items(): + lines.append(f'h[{idx}]={port_gas.get(endpoint,default_h.removesuffix(".h"))}.h;' if endpoint in port_gas else f'h[{idx}]={default_h};') + for c in components: + if c.model_type in {'amesim_pnpl01','amesim_pnrp17'}: + # Closed-end ports retain their declared zero outflow enthalpy. + lines.append(f'{h(c,"port_1")}=0.0;') + + flow_lines, flow_known, flow_eq = [], set(), [] + def flow(c, name, expr): + target=q(c,name);flow_lines.append(f'{target}={expr};');flow_known.add(target) + def flow_equation(terms): + flow_eq.append(({q(c,name):coef for c,name,coef in terms},'0.0')) + for c in components: + kind=c.model_type + names=pnames(c) + if kind in RESISTORS or kind in NODES: + flow_equation([(c,name,1) for name in names]) + if kind in {'amesim_pnpl01','amesim_pnrp17'}: + flow(c,'port_1','0.0') + if kind in RESISTORS: + a,b=names + pa,pb=p(c,a),p(c,b) + if kind=='pipe' and c.lambda_darcy==0: + # A zero-loss pipe is an ideal connection, whose flow is + # determined by the neighbouring constitutive equations. + continue + if kind=='orifice': + flow(c,a,f'{num(c.K_eff)}*copysign(sqrt(fabs({pa}-{pb})),{pa}-{pb})') + elif kind=='pipe': + resistance=c.lambda_darcy*c.L/c.D + flow(c,a,f'copysign(sqrt(fabs({pa}-{pb})*2*fmax(native_density({medium(c)},fmax(.5*({pa}+{pb}),1),{num(c.T0)}),1e-12)*{num(c.area*c.area/resistance)}),{pa}-{pb})') + elif kind=='amesim_pnl00r': + T=f'native_temperature_ph({medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})' + flow(c,a,f'native_pipe_flow({medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0)') + else: + opening = w(c,'xv') if kind!='amesim_pnor001' else '1.0' + if kind!='amesim_pnor001': + value = f'fmax(0,fmin(1,{w(c,"res.signal")}))' if kind=='amesim_pnvo001' else num(c.opening) + put(c,'xv',value) + area = c.effective_cq*(c.effective_area if kind=='amesim_pnor001' else c.maximum_area) + flow_lines.append(f'if(!native_medium_orifice({medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening},&{q(c,a)},&{w(c,"cm")},&{w(c,"gasvel")})) return 0;') + flow_known.add(q(c,a));assigned.update((c.name+'.cm',c.name+'.gasvel')) + flow(c,b,f'-{q(c,a)}') + elif kind in ('amesim_pnl0001','amesim_pnl0002'): + gas=gases[c.name,0] + for name in (['port_1'] if kind.endswith('1') else names): + T=gas+'.T' + if kind.endswith('2'): + T=f'({p(c,name)}>{gas}.p?native_temperature_ph({medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)' + flow(c,name,f'native_pipe_flow({medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1)') + for edge in network.connections: + if edge.domain=='pneumatic': + flow_eq.append(({f'q[{pi[e.key]}]':1 for e in edge.endpoints},'0.0')) + unknownq=[f'q[{i}]' for i in range(len(pneu)) if f'q[{i}]' not in flow_known] + reduced=[] + for terms,rhs in flow_eq: + known=''.join(f'-({num(v)})*{k}' for k,v in terms.items() if k in flow_known) + reduced.append(({k:v for k,v in terms.items() if k not in flow_known},rhs+known)) + free_flows={f'q[{pi[e]}]' for root,_,_ in coupled for e in pneu if groups.find(e)==root} + flow_lines += linear_schedule(reduced,unknownq,free_flows) + + unknownp=[root for root in pgroups if root not in anchor] + residuals=[] + for root in unknownp: + terms=[f'q[{pi[e]}]' for e in pneu if groups.find(e)==root and f'q[{pi[e]}]' in flow_known] + if not terms: + raise NativeCapabilityError('Unanchored pneumatic pressure group has no constitutive flow relation') + residuals.append(' + '.join(terms)) + # A monotone nodal mass-balance solve. This is an algebraic connection + # closure; the integration solver's Jacobian policy is unchanged. + pressure_lines=[] + if unknownp: + if not anchor: + raise NativeCapabilityError('Pneumatic pressure network has no storage pressure anchor') + pressure_lines += ['double plo=INFINITY,phi=0;', *[f'plo=fmin(plo,{g}.p);phi=fmax(phi,{g}.p);' for g in anchor.values()]] + pressure_lines += [f'p[{pgi[root]}]=.5*(plo+phi);' for root in unknownp] + pressure_lines += ['int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {'] + for root,expr in zip(unknownp,residuals): + pressure_lines += ['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',f'p[{pgi[root]}]=.5*(lo+hi);', + 'if(!model_flows(p,h,g,w,q)) return 0;', f'if(({expr})>0) hi=p[{pgi[root]}];else lo=p[{pgi[root]}];','}}'] + pressure_lines += ['if(!model_flows(p,h,g,w,q)) return 0;', 'double residual=0;', *[f'residual=fmax(residual,fabs({expr}));' for expr in residuals], + 'if(residual<1e-11) { pressure_ok=1;break; }','}', 'if(!pressure_ok) return 0;'] + else: + pressure_lines=['if(!model_flows(p,h,g,w,q)) return 0;'] + + stream_lines=[] + for c in components: + names=pnames(c);kind=c.model_type + if kind in {'amesim_pnpl01','amesim_pnrp17'}: + stream_lines.append(f'{h(c,"port_1")}={hin(c,"port_1")};') + elif kind in RESISTORS: + a,b=names + stream_lines += [f'{h(c,a)}={hin(c,b)};',f'{h(c,b)}={hin(c,a)};'] + elif kind in NODES: + stream_lines += ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]] + if kind=='tee': + stream_lines += [f'double mixed=total>1e-12?energy/total:average/{len(names)};', *[f'{h(c,name)}=mixed;' for name in names]] + else: + stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', *[f'{h(c,name)}=ref;' for name in names if name!='port_2'],f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;', + *[f'e+={q(c,name)}*({q(c,name)}>1e-12?{hin(c,name)}:ref);scale+=fabs({q(c,name)});' for name in names if name!='port_2'], + f'double flow={q(c,"port_2")},transition=fmax(.05*scale,1e-12);', + 'double inv=-flow>=transition?1/flow:flow*(2*transition*transition-flow*flow)/pow(transition,4);', + f'{h(c,"port_2")}=mixed-(e+flow*mixed)*inv;','}'] + # Nodes expose their reference temperature as an output too. + if c.name+'.T' in slots: + raise NativeCapabilityError('Unexpected node temperature output contract') + stream_lines += ['}'] + if pneu: + lines += ['int closure_ok=0;',f'for(int closure=0;closure<{max(64,4*len(pneu))};closure++) {{',f'double previous[{len(pneu)}];',f'for(int i=0;i<{len(pneu)};i++) previous[i]=h[i];',*pressure_lines,*stream_lines, + 'double change=0;',f'for(int i=0;i<{len(pneu)};i++) change=fmax(change,fabs(h[i]-previous[i])/fmax(1,fabs(h[i])));', + 'if(change<1e-12) {closure_ok=1;break;}','}', 'if(!closure_ok) return 0;', 'if(!model_flows(p,h,g,w,q)) return 0;'] + for c in components: + for name in pnames(c): + for field,expr in [('p',p(c,name)),('m_flow',q(c,name)),('h_outflow',h(c,name))]: + put(c,name+'.'+field,expr) + + # Mechanical force balance includes shared accelerations for rigid groups. + feq=[] + for edge in network.connections: + if edge.domain=='mechanical': + feq.append(({w(e.component,e.port+'.f'):1 for e in edge.endpoints},'0.0')) + for c in components: + kind=c.model_type + if kind=='amesim_forc': + put(c,'force',f'{num(c.direction)}*{w(c,"res.signal")}') + feq.append(({w(c,'port_2.f'):1},'-'+w(c,'force'))) + elif kind=='amesim_f000': + feq.append(({w(c,'port_1.f'):1},'0.0')) + elif kind=='amesim_lstp00a': + put(c,'gap',f'{num(c.gap0)}+{w(c,"port_2.x")}-{w(c,"port_1.x")}') + put(c,'penetration',f'fmax(-{w(c,"gap")},0)') + put(c,'force',f'native_contact({w(c,"penetration")},{w(c,"port_1.v")}-{w(c,"port_2.v")},{num(c.kcont)},{num(c.rcont)},{num(c.Pdis)},{int(c.discContactOption)})') + feq += [({w(c,'port_1.f'):1},w(c,'force')),({w(c,'port_2.f'):1},'-'+w(c,'force'))] + elif kind=='amesim_pnrp17': + put(c,'pressure_force',f'({w(c,"port_1.p")}-101300)*{num(c.effective_area)}') + feq += [({w(c,'port_2.f'):1,w(c,'port_5.f'):1},'-'+w(c,'pressure_force')),({w(c,'port_3.f'):1,w(c,'port_4.f'):1},w(c,'pressure_force'))] + elif kind=='amesim_lmechn1': + feq.append(({w(c,name+'.f'):1 for name in mnames(c)},'0.0')) + elif kind=='amesim_mecmas21': + v,x=w(c,'v'),w(c,'x') + put(c,'Fvisc',f'-{num(c.rvisc)}*{v}' if c.use_friction else '0.0') + put(c,'Ffric',f'{v}>0?-{num(c.fcoul)}:({v}<0?{num(c.fcoul)}:0)' if c.use_friction else '0.0') + for field,penetration,velocity,suffix in [('Fmin',num(c.xmin)+'-'+x,'-'+v,'min'),('Fmax',x+'-'+num(c.xmax),v,'max')]: + expr=f'native_limit_force({penetration},{velocity},{num(getattr(c,"Kb"+suffix))},{num(getattr(c,"Db"+suffix))},{num(getattr(c,"Pd"+suffix))},{int(c.discContactOption)})' if int(c.stoptype)==2 else '0.0' + put(c,field,expr) + ref=mass_groups[groups.find(ep(c,'port_1'))][0] + extra=f'{w(c,"Fvisc")}+{w(c,"Ffric")}+{w(c,"Fmin")}-{w(c,"Fmax")}' + if c.use_friction: extra+=f'-{num(c.wind)}*{v}*fabs({v})' + feq.append(({w(c,'port_1.f'):1,w(c,'port_2.f'):1,w(ref,'a'):-c.mass},f'-({extra})')) + unknownf=[w(c,name+'.f') for c in components for name in mnames(c)]+[w(group[0],'a') for group in mass_groups.values()] + lines += linear_schedule(feq,unknownf) + for c in components: + for name in mnames(c): assigned.add(c.name+'.'+name+'.f') + if c.model_type=='amesim_lmechn1': + put(c,'tforce',' + '.join(w(c,name+'.f') for name in mnames(c)[:-1])) + stops=[] + for group in mass_groups.values(): + ref=group[0];vi=states[ref.name,'v'];xi=states[ref.name,'x'] + limits=[c for c in group if int(c.stoptype) in (1,3)] + lines += [f'dy[{vi}]={w(ref,"a")};dy[{xi}]={y(ref,"v")};'] + if limits: + lower=max(c.xmin for c in limits);upper=min(c.xmax for c in limits) + if lower>upper or ref.x0upper+1e-12: + raise NativeCapabilityError('Inconsistent discrete endstop bounds or initial position') + restitution, thresholds = [], [] + for parameter, bound in [('xmin',lower),('xmax',upper)]: + active=[c for c in limits if abs(getattr(c,parameter)-bound)<=1e-12*max(abs(bound),1)] + restitution.append(0 if any(int(c.stoptype)==1 for c in active) else min(c.restcoeff for c in active)) + thresholds.append(max((c.restdvel for c in active if int(c.stoptype)==3),default=0)) + stops.append((vi,lower,upper,*restitution,*thresholds)) + lines.append(f'native_stop_motion({y(ref,"x")},{y(ref,"v")},{num(lower)},{num(upper)},&dy[{vi}],&dy[{xi}]);') + for c in group: put(c,'a',f'dy[{vi}]') + + for c in components: + kind=c.model_type + if kind not in GAS_TYPES and kind!='amesim_pnl00r': continue + if kind in GAS_TYPES: + halves=(1,2) if kind=='amesim_pnl0003' else (0,) + center='0.0' + if kind=='amesim_pnl0003': + a,b=gases[c.name,1],gases[c.name,2] + center=w(c,'dmctr') + put(c,'dmctr',f'native_pipe_flow({medium(c)},{a}.p,{b}.p,{a}.p>={b}.p?{a}.T:{b}.T,{num(c.diam)},{num(c.le)},{num(c.rr)},3)') + for half in halves: + gas=gases[c.name,half];suffix=str(half) if half else '' + names=['port_'+suffix] if half else pnames(c) + mass=' + '.join(q(c,name) for name in names) + energy=' + '.join(f'{q(c,name)}*({q(c,name)}>0?{hin(c,name)}:{gas}.h)' for name in names) + if half: + sign='-' if half==1 else '+' + mass+=sign+center + energy+=f'{sign}{center}*({center}>0?{gases[c.name,1]}.h:{gases[c.name,2]}.h)' + heat='0.0' + if kind.startswith('amesim_pnch'): + heat=f'{num(c.kth*c.sth)}*({num(c.extemp)}-{gas}.T)-{gas}.p*({volume_rate[c.name]})' + elif kind.startswith('amesim_pnl') and int(c.mode)!=1: + temp=f'.5*({gases[c.name,1]}.T+{gases[c.name,2]}.T)' if half else gas+'.T' + heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))' + lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,"U"+suffix]}]={energy}+({heat});'] + if kind.startswith('amesim_pnl'): + diag=[] + if kind=='amesim_pnl0002': + gas=gases[c.name,0] + for name in pnames(c): + flow=q(c,name);pp=f'({flow}>=0?fmax({p(c,name)},1):fmax({gas}.p,1))' + temp=f'({flow}>=0?fmax(native_temperature_ph({medium(c)},{pp},{hin(c,name,True)}),1):{gas}.T)' + diag.append((flow,pp,temp,c.le/2,0)) + elif kind=='amesim_pnl0003': + a,b=gases[c.name,1],gases[c.name,2];flow=w(c,'dmctr') + diag=[(flow,f'fmax(fmax({a}.p,{b}.p),1)',f'({flow}>=0?{a}.T:{b}.T)',c.le,1)] + else: + pa,pb=p(c,'port_1'),p(c,'port_2');pp=f'fmax(fmax({pa},{pb}),1)' + temp=gases[c.name,0]+'.T' if kind=='amesim_pnl0001' else f'fmax(native_temperature_ph({medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)' + diag=[(q(c,'port_1'),pp,temp,c.le,0)] + lines.append('{ double d[4],acc[4]={0};') + for flow,pp,temp,length,diagnostic in diag: + lines.append(f'native_pipe_diagnostics({medium(c)},{flow},{pp},{temp},{num(c.diam)},{num(length)},{num(c.rr)},{diagnostic},d);') + if len(diag)>1: lines.append('d[2]=fabs(d[2]);') + lines.append('for(int i=0;i<4;i++) acc[i]+=d[i];') + for i,field in enumerate(('re','cm','v','ff')): + expr=f'acc[{i}]/{len(diag)}' + put(c,field,f'fmin({expr},64000000)' if field=='ff' else expr) + lines.append('}') + for _,offsets,volumes in coupled: + lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',energy='+ '+'.join(f'dy[{i+1}]' for i in offsets)+';') + for i,volume in zip(offsets,volumes): + lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=energy*{num(volume/sum(volumes))};') + lines.append('}') + missing=set(slots)-assigned + if missing: raise NativeCapabilityError(f'Native output mapping incomplete: {sorted(missing)}') + if not state_keys: lines.append('dy[0]=0;') + np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu)) + source='\n'.join(['#include "model.h"','#include ',*declarations, + f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};', + 'const double model_atol[NSTATES] = {'+','.join('1e-12' if k.endswith(('.v','.x')) else '1e-8' for k in state_keys or ['dummy'])+'};', + 'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};', + 'static int model_flows(const double *p,const double *h,const NativeGas *g,double *w,double *q) {', + '(void)p;(void)h;(void)g;(void)w;(void)q;',*flow_lines,'return 1;}', + 'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}', + 'int model_eval(double t,const double *y,double *dy,double *w) {', + *(['double projected[NSTATES];for(int i=0;i float: + """Bounded arithmetic only: never evaluate code, names or function calls.""" + source = source.strip().removeprefix("=").strip() + if len(source) > 512: + raise ValueError("Parameter expression exceeds 512 characters.") + tree = ast.parse(source, mode="eval") + if sum(1 for _ in ast.walk(tree)) > 256: + raise ValueError("Parameter expression is too complex.") + def visit(node, depth=0): + if depth > 32: + raise ValueError("Parameter expression is nested too deeply.") + if isinstance(node, ast.Constant) and type(node.value) in (int, float): + result = float(node.value) + elif isinstance(node, ast.UnaryOp) and type(node.op) in (ast.UAdd, ast.USub): + result = visit(node.operand, depth+1) * (-1 if isinstance(node.op, ast.USub) else 1) + elif isinstance(node, ast.BinOp) and type(node.op) in _OPERATIONS: + a, b = visit(node.left, depth+1), visit(node.right, depth+1) + if isinstance(node.op, ast.Pow) and abs(b) > 16: + raise ValueError("CLI parameter exponents must be between -16 and 16.") + result = _OPERATIONS[type(node.op)](a, b) + else: + raise ValueError("CLI supports arithmetic expressions only; export complex expressions as XML from the editor.") + if not isinstance(result, (int, float)) or not isfinite(result): + raise ValueError("Parameter expression must produce a finite real number.") + return result + return float(visit(tree.body)) + + +def project_xml(data: dict) -> bytes: + from app.main import ReactFlowProjectPayload, build_reactflow_system_xml + from app.simulation.registry import get_component_model_spec + normalized = deepcopy(data) + for node in normalized.get("nodes", []): + model = node["data"] + spec = get_component_model_spec(model["modelType"]) + for name, value in list(model.get("parameters", {}).items()): + if not isinstance(value, str): + continue + try: + number = float(value) + except ValueError: + definition = spec.parameter_by_name[name] + if definition.editor: + raise ValueError(f"{node['id']}.{name}: a discrete parameter cannot be an expression.") + number = arithmetic_value(value) + unit = model.get("parameterUnits", {}).get(name, definition.unit) + if definition.quantity in _SCALES: + if unit not in _SCALES[definition.quantity]: + raise ValueError(f"Unsupported parameter unit: {unit}.") + number *= _SCALES[definition.quantity][unit] + elif definition.quantity == "temperature" and unit == "degC": + number += 273.15 + elif unit != definition.unit: + raise ValueError(f"Unsupported parameter unit: {unit}.") + if not isfinite(number): + raise ValueError(f"{node['id']}.{name}: parameter must be finite.") + model["parameters"][name] = number + return build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(normalized)) + + +def load_input(path: Path): + xml = project_xml(json.loads(path.read_text(encoding="utf-8"))) if path.suffix.lower() == ".json" else path.read_bytes() + report = validate_system_xml_document(xml) + if not report.valid or report.document is None: + raise ValueError(f"Invalid simulation XML: {report.as_dict()}") + return xml, report.document diff --git a/app/simulation/native_codegen/runner.py b/app/simulation/native_codegen/runner.py new file mode 100644 index 0000000..927da10 --- /dev/null +++ b/app/simulation/native_codegen/runner.py @@ -0,0 +1,128 @@ +"""Isolated native numerical execution; Python only handles process I/O.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import queue +import subprocess +import tempfile +import threading +import time + +from app.simulation.config import SolveIVPConfig +from app.simulation.results import GenericSimulationResult +from .build import NativeBuild, build_native +from .compiler import NativeCapabilityError, compile_native_program + + +def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *, + run_dir: Path, record_samples=True, cancel_check=None, + progress_callback=None, activity_tracker=None, timeout=300.0) -> dict: + if config.method not in ("RK45", "BDF"): + raise NativeCapabilityError(f"Native v1 does not support method {config.method}.") + if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None: + raise NativeCapabilityError("Native v1 uses the existing default gas/mechanical absolute tolerances and automatic initial step.") + run_dir.mkdir(parents=True, exist_ok=True) + output = run_dir / "result.json" + cancel_path = run_dir / "cancel.request" + if output.exists() or cancel_path.exists(): + raise ValueError("Native execution requires a fresh run directory.") + command = [str(build.executable), "--method", config.method, + "--start", str(config.t_start), "--stop", str(config.t_stop), + "--sample-step", str(sample_step), "--max-step", str(config.max_step), + "--rtol", str(config.rtol), "--timeout", str(timeout), + "--cancel-file", str(cancel_path.resolve()), "--output", str(output.resolve())] + if not record_samples: + command.append("--solve-only") + creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + started = time.perf_counter() + process = subprocess.Popen(command, cwd=build.executable.parent, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + text=True, encoding="utf-8", errors="replace", creationflags=creationflags) + messages: queue.Queue[str] = queue.Queue() + def read_stderr(): + assert process.stderr is not None + for line in process.stderr: + messages.put(line) + reader = threading.Thread(target=read_stderr, daemon=True) + reader.start() + if activity_tracker is not None: + activity_tracker.start_integration(config.t_start) + cancelled_at = None + last_time = config.t_start + try: + with (run_dir / "worker.log").open("w", encoding="utf-8") as log: + while process.poll() is None or not messages.empty() or reader.is_alive(): + now = time.perf_counter() + if cancel_check is not None and cancel_check() and cancelled_at is None: + cancel_path.write_text("cancel\n", encoding="ascii") + cancelled_at = now + if now-started > timeout+5 or (cancelled_at is not None and now-cancelled_at > 5): + process.kill() + process.wait(timeout=5) + raise RuntimeError("Native worker was terminated after failing to return within its time limit.") + try: + line = messages.get(timeout=0.05) + except queue.Empty: + continue + log.write(line) + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("phase") == "integrating": + last_time = max(last_time, min(config.t_stop, float(event["time"]))) + if progress_callback: + progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating") + if activity_tracker is not None: + activity_tracker.record_native_progress(last_time, int(event["nfev"]), int(event["acceptedSteps"])) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + reader.join(timeout=2) + if process.stderr is not None: + process.stderr.close() + if not output.is_file(): + raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.") + payload = json.loads(output.read_text(encoding="utf-8")) + if process.returncode not in (0, 2): + raise RuntimeError(f"Native worker failed with exit code {process.returncode}.") + payload["processWallSeconds"] = time.perf_counter()-started + payload["buildKey"] = build.manifest["buildKey"] + payload["cacheHit"] = build.cache_hit + payload["buildSeconds"] = build.seconds + if activity_tracker is not None: + activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"]) + return payload + + +def simulate_native(network, config, *, sample_step, progress_callback=None, + cancel_check=None, activity_tracker=None): + if config.method not in ("RK45", "BDF"): + raise NativeCapabilityError(f"Native v1 does not support method {config.method}.") + if progress_callback: + progress_callback(0.0, "initializing") + program = compile_native_program(network) + build = build_native(program) + with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory: + data = execute_native(build, config, sample_step, run_dir=Path(directory), + cancel_check=cancel_check, progress_callback=progress_callback, + activity_tracker=activity_tracker) + totals = { + "nfev": data["nfev"], "njev": data["njev"], "nlu": data["nlu"], + "acceptedStepCount": data["acceptedSteps"], "rejectedStepCount": data["rejectedSteps"], + "stateTransitionCount": data["stateTransitions"], "solverStartCount": data["solverStarts"], + } + if progress_callback: + fraction = (data["simulatedUntil"]-config.t_start)/(config.t_stop-config.t_start) + progress_callback(fraction, "complete" if data["success"] else data["status"]) + return GenericSimulationResult( + success=data["success"], status=data["status"], message=data["message"], + simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop, + variables=program.variables, series=data["series"], final=data["final"], + diagnostics={"backend": "native-c", "native": {k: v for k, v in data.items() + if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "totals": totals}, + "stateCount": len(program.state_keys), "sampleCount": len(data["series"]["time"])}, + ) diff --git a/app/simulation/performance.py b/app/simulation/performance.py index d09a703..6f27c9d 100644 --- a/app/simulation/performance.py +++ b/app/simulation/performance.py @@ -1,32 +1,19 @@ -"""Low-overhead, run-local performance instrumentation for simulations. - -The profiling mode is intentionally read once when this module is imported. -``standard`` records low-frequency pipeline stages, while ``audit`` also wraps -hot RHS/property operations and computes exact-input reuse metrics. With -profiling disabled, decorators return the original callable while classes are -being defined, so ordinary simulation calls do not pass through a wrapper. -""" - +"""Run-local timing of Python orchestration; numeric timings come from C.""" from __future__ import annotations - from collections.abc import Callable, Generator, Mapping from contextlib import contextmanager -from contextvars import ContextVar, Token +from contextvars import ContextVar from dataclasses import dataclass, field from functools import wraps import inspect import os -import struct from time import perf_counter_ns from typing import Any, Literal, ParamSpec, TypeVar, cast - -ProfileMode = Literal["off", "standard", "audit"] - -_P = ParamSpec("_P") -_R = TypeVar("_R") -_MODE_RANK: Mapping[ProfileMode, int] = {"off": 0, "standard": 1, "audit": 2} - +ProfileMode = Literal['off', 'standard', 'audit'] +_P = ParamSpec('_P') +_R = TypeVar('_R') +_MODE_RANK: Mapping[ProfileMode, int] = {'off': 0, 'standard': 1, 'audit': 2} def _read_startup_mode() -> ProfileMode: raw_mode = os.getenv("SIMULATIONAPP_PROFILE", "off").strip().lower() @@ -50,17 +37,12 @@ def _read_startup_mode() -> ProfileMode: "SIMULATIONAPP_PROFILE must be one of: off, standard, audit." ) from exc - -PROFILE_MODE: ProfileMode = _read_startup_mode() - - def _minimum_mode(value: str) -> ProfileMode: normalized = value.strip().lower() if normalized not in _MODE_RANK: raise ValueError("minimum_mode must be one of: off, standard, audit.") return cast(ProfileMode, normalized) - def _mode_enabled(minimum_mode: ProfileMode) -> bool: # ``off`` is an unconditional zero-wrapper mode, even if a caller passes # ``minimum_mode="off"`` by mistake. @@ -69,7 +51,6 @@ def _mode_enabled(minimum_mode: ProfileMode) -> bool: and _MODE_RANK[PROFILE_MODE] >= _MODE_RANK[minimum_mode] ) - @dataclass class _TimingStats: calls: int = 0 @@ -95,311 +76,10 @@ class _TimingStats: "errors": self.errors, } - -@dataclass -class _PropertyStats(_TimingStats): - operation: str = "" - layer: str = "semantic" - medium: str = "unknown" - exact_input_unique: int = 0 - exact_input_repeats: int = 0 - iteration_calls: int = 0 - iteration_total: int = 0 - iteration_max: int = 0 - iteration_converged: int = 0 - iteration_nonconverged: int = 0 - cache_lookups: int = 0 - cache_hits: int = 0 - cache_misses: int = 0 - - def snapshot(self, *, audit: bool) -> dict[str, object]: - result: dict[str, object] = super().snapshot() - result.update( - { - "operation": self.operation, - "layer": self.layer, - "medium": self.medium, - "cacheLookups": self.cache_lookups, - "cacheHits": self.cache_hits, - "cacheMisses": self.cache_misses, - } - ) - if audit: - result.update( - { - "exactInputUnique": self.exact_input_unique, - "exactInputRepeats": self.exact_input_repeats, - "iterationCalls": self.iteration_calls, - "iterationTotal": self.iteration_total, - "iterationMax": self.iteration_max, - "iterationConverged": self.iteration_converged, - "iterationNonconverged": self.iteration_nonconverged, - } - ) - return result - - -@dataclass -class _ActiveSpan: - trace: PerformanceTrace - name: str - started_ns: int - property_key: str | None = None - property_operation: str | None = None - property_outermost: bool = False - child_ns: int = 0 - - -@dataclass -class PerformanceTrace: - """Mutable counters owned by exactly one :func:`profile_run` context.""" - - mode: ProfileMode - _phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False) - _properties: dict[str, _PropertyStats] = field(default_factory=dict, repr=False) - _property_outermost_ns: int = field(default=0, repr=False) - - @property - def enabled(self) -> bool: - return self.mode != "off" - - def _property_stats( - self, - key: str, - *, - operation: str, - layer: str, - medium: str, - ) -> _PropertyStats: - stats = self._properties.get(key) - if stats is None: - stats = _PropertyStats( - operation=operation, - layer=layer, - medium=medium, - ) - self._properties[key] = stats - return stats - - def _record_span(self, frame: _ActiveSpan, elapsed_ns: int, error: bool) -> None: - self_ns = max(0, elapsed_ns - frame.child_ns) - if frame.property_key is None: - stats = self._phases.setdefault(frame.name, _TimingStats()) - else: - layer, medium, operation = frame.property_key.split("|", 2) - stats = self._property_stats( - frame.property_key, - operation=operation, - layer=layer, - medium=medium, - ) - if frame.property_outermost: - self._property_outermost_ns += elapsed_ns - stats.record(elapsed_ns, self_ns, error) - - def _record_exact_input( - self, - key: str, - *, - operation: str, - layer: str, - medium: str, - fingerprint: object, - ) -> None: - stats = self._property_stats( - key, - operation=operation, - layer=layer, - medium=medium, - ) - shadow_key = (key, fingerprint) - shadow = _PROPERTY_SHADOW.get() - if shadow is None: - # A trace normally installs its own set in ``profile_run``. Keep - # the ContextVar default immutable so no task can accidentally - # share a process-global shadow set. - shadow = set() - _PROPERTY_SHADOW.set(shadow) - if shadow_key in shadow: - stats.exact_input_repeats += 1 - else: - shadow.add(shadow_key) - stats.exact_input_unique += 1 - - def _record_iterations( - self, - key: str, - *, - operation: str, - layer: str, - medium: str, - iterations: int, - converged: bool, - ) -> None: - stats = self._property_stats( - key, - operation=operation, - layer=layer, - medium=medium, - ) - iteration_count = max(0, int(iterations)) - stats.iteration_calls += 1 - stats.iteration_total += iteration_count - stats.iteration_max = max(stats.iteration_max, iteration_count) - if converged: - stats.iteration_converged += 1 - else: - stats.iteration_nonconverged += 1 - - def _record_cache( - self, - key: str, - *, - operation: str, - layer: str, - medium: str, - hits: int, - misses: int, - ) -> None: - stats = self._property_stats( - key, - operation=operation, - layer=layer, - medium=medium, - ) - hit_delta = max(0, hits) - miss_delta = max(0, misses) - stats.cache_hits += hit_delta - stats.cache_misses += miss_delta - stats.cache_lookups += hit_delta + miss_delta - - def snapshot(self) -> dict[str, object]: - """Return a detached, JSON-serializable copy of all counters.""" - - audit = self.mode == "audit" - return { - "mode": self.mode, - "phases": { - name: self._phases[name].snapshot() - for name in sorted(self._phases) - }, - "properties": { - _public_property_key(key): self._properties[key].snapshot(audit=audit) - for key in sorted(self._properties) - }, - "propertyOutermostNs": self._property_outermost_ns, - } - - -_CURRENT_TRACE: ContextVar[PerformanceTrace | None] = ContextVar( - "simulation_performance_trace", - default=None, -) -_ACTIVE_SPANS: ContextVar[tuple[_ActiveSpan, ...]] = ContextVar( - "simulation_performance_spans", - default=(), -) -_PROPERTY_SHADOW: ContextVar[set[object] | None] = ContextVar( - "simulation_property_shadow", - default=None, -) - - -def _public_property_key(key: str) -> str: - layer, medium, operation = key.split("|", 2) - return f"{layer}.{medium}.{operation}" - - -@contextmanager -def _tracked_span( - trace: PerformanceTrace, - name: str, - *, - property_key: str | None = None, - property_operation: str | None = None, - reset_property_shadow: bool = False, -) -> Generator[None, None, None]: - stack = _ACTIVE_SPANS.get() - property_outermost = property_key is not None and not any( - item.property_key is not None for item in stack - ) - frame = _ActiveSpan( - trace=trace, - name=name, - started_ns=perf_counter_ns(), - property_key=property_key, - property_operation=property_operation, - property_outermost=property_outermost, - ) - stack_token = _ACTIVE_SPANS.set((*stack, frame)) - shadow_token: Token[set[object] | None] | None = None - if reset_property_shadow and trace.mode == "audit": - shadow_token = _PROPERTY_SHADOW.set(set()) - error = False - try: - yield - except BaseException: - error = True - raise - finally: - elapsed_ns = max(0, perf_counter_ns() - frame.started_ns) - _ACTIVE_SPANS.reset(stack_token) - if shadow_token is not None: - _PROPERTY_SHADOW.reset(shadow_token) - if stack: - stack[-1].child_ns += elapsed_ns - trace._record_span(frame, elapsed_ns, error) - - -@contextmanager -def profile_run() -> Generator[PerformanceTrace, None, None]: - """Create and bind an isolated trace for one simulation run. - - The yielded trace remains usable after the context exits, which lets the - caller attach ``trace.snapshot()`` to a result without exposing live state. - """ - - trace = PerformanceTrace(mode=PROFILE_MODE) - trace_token = _CURRENT_TRACE.set(trace) - spans_token = _ACTIVE_SPANS.set(()) - shadow_token = _PROPERTY_SHADOW.set(set()) - try: - if trace.enabled: - with _tracked_span(trace, "simulation.total"): - yield trace - else: - yield trace - finally: - _PROPERTY_SHADOW.reset(shadow_token) - _ACTIVE_SPANS.reset(spans_token) - _CURRENT_TRACE.reset(trace_token) - - -@contextmanager -def performance_span( - name: str, - minimum_mode: str = "standard", - reset_property_shadow: bool = False, -) -> Generator[None, None, None]: - """Time a block in the current run, or act as a no-op outside one.""" - - minimum = _minimum_mode(minimum_mode) - trace = _CURRENT_TRACE.get() - if trace is None or not _mode_enabled(minimum): - yield - return - with _tracked_span( - trace, - name, - reset_property_shadow=reset_property_shadow, - ): - yield - - def profile_phase( name: str, minimum_mode: str = "standard", - reset_property_shadow: bool = False, + ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """Decorate a simulation phase while preserving the off-mode callable.""" @@ -419,7 +99,7 @@ def profile_phase( with _tracked_span( trace, name, - reset_property_shadow=reset_property_shadow, + ): return await function(*args, **kwargs) @@ -433,7 +113,7 @@ def profile_phase( with _tracked_span( trace, name, - reset_property_shadow=reset_property_shadow, + ): return function(*args, **kwargs) @@ -441,249 +121,68 @@ def profile_phase( return decorate +PROFILE_MODE = _read_startup_mode() -def _medium_name(args: tuple[object, ...]) -> str: - if not args: - return "unknown" - owner = args[0] - configured_name = getattr(owner, "name", None) - if isinstance(configured_name, str) and configured_name: - return configured_name - return type(owner).__name__ +@dataclass +class _ActiveSpan: + name: str + started_ns: int + child_ns: int = 0 +@dataclass +class PerformanceTrace: + mode: ProfileMode + _phases: dict[str, _TimingStats] = field(default_factory=dict, repr=False) -def _fingerprint(value: object) -> object: - """Build a hashable, bit-exact token without retaining arbitrary objects.""" + @property + def enabled(self): + return self.mode != 'off' - if value is None or isinstance(value, (bool, int, str, bytes)): - return (type(value).__name__, value) - if isinstance(value, float): - return ("float64", struct.pack("!d", value)) - if isinstance(value, tuple): - return ("tuple", tuple(_fingerprint(item) for item in value)) - if isinstance(value, list): - return ("list", tuple(_fingerprint(item) for item in value)) - if isinstance(value, Mapping): - items = [(_fingerprint(key), _fingerprint(item)) for key, item in value.items()] - items.sort(key=repr) - return ("mapping", tuple(items)) - return ( - "object", - type(value).__module__, - type(value).__qualname__, - id(value), - ) + def snapshot(self): + return {'mode': self.mode, 'phases': {key: value.snapshot() for key, value in sorted(self._phases.items())}} +_CURRENT_TRACE = ContextVar('simulation_trace', default=None) +_ACTIVE_SPANS = ContextVar('simulation_spans', default=()) -def _input_fingerprint( - signature: inspect.Signature | None, - args: tuple[object, ...], - kwargs: dict[str, object], -) -> object: - if signature is not None: - try: - bound = signature.bind(*args, **kwargs) - bound.apply_defaults() - return tuple( - (name, _fingerprint(value)) - for name, value in bound.arguments.items() - ) - except TypeError: - pass - return ( - _fingerprint(args), - tuple(sorted((name, _fingerprint(value)) for name, value in kwargs.items())), - ) - - -def _cache_counts(function: Callable[..., object]) -> tuple[int, int] | None: - cache_info = getattr(function, "cache_info", None) - if not callable(cache_info): - return None +@contextmanager +def _tracked_span(trace, name): + stack = _ACTIVE_SPANS.get() + frame = _ActiveSpan(name, perf_counter_ns()) + token = _ACTIVE_SPANS.set((*stack, frame)) + error = False try: - info = cache_info() - return int(info.hits), int(info.misses) - except (AttributeError, TypeError, ValueError): - return None + yield + except BaseException: + error = True + raise + finally: + elapsed = max(0, perf_counter_ns()-frame.started_ns) + _ACTIVE_SPANS.reset(token) + if stack: + stack[-1].child_ns += elapsed + trace._phases.setdefault(name, _TimingStats()).record(elapsed, max(0, elapsed-frame.child_ns), error) +@contextmanager +def profile_run(): + trace = PerformanceTrace(PROFILE_MODE) + token = _CURRENT_TRACE.set(trace) + spans = _ACTIVE_SPANS.set(()) + try: + if trace.enabled: + with _tracked_span(trace, 'simulation.total'): + yield trace + else: + yield trace + finally: + _ACTIVE_SPANS.reset(spans) + _CURRENT_TRACE.reset(token) -def _copy_cache_api(source: Callable[..., object], target: Callable[..., object]) -> None: - for attribute in ("cache_clear", "cache_info", "cache_parameters"): - value = getattr(source, attribute, None) - if value is not None: - setattr(target, attribute, value) - - -def profile_property( - operation: str, - layer: str = "semantic", - minimum_mode: str = "audit", - capture_inputs: bool = True, - track_cache: bool = False, -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Decorate one thermodynamic property operation.""" - +@contextmanager +def performance_span(name: str, minimum_mode: str = 'standard'): minimum = _minimum_mode(minimum_mode) - - def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]: - if not _mode_enabled(minimum): - return function - try: - signature: inspect.Signature | None = inspect.signature(function) - except (TypeError, ValueError): - signature = None - - def prepare( - args: tuple[object, ...], - kwargs: dict[str, object], - ) -> tuple[PerformanceTrace | None, str, str, tuple[int, int] | None]: - trace = _CURRENT_TRACE.get() - medium = _medium_name(args) - key = f"{layer}|{medium}|{operation}" - if trace is not None and trace.mode == "audit" and capture_inputs: - trace._record_exact_input( - key, - operation=operation, - layer=layer, - medium=medium, - fingerprint=_input_fingerprint(signature, args, kwargs), - ) - before = _cache_counts(function) if trace is not None and track_cache else None - return trace, medium, key, before - - def finish_cache( - trace: PerformanceTrace | None, - medium: str, - key: str, - before: tuple[int, int] | None, - ) -> None: - if trace is None or before is None: - return - after = _cache_counts(function) - if after is None: - return - trace._record_cache( - key, - operation=operation, - layer=layer, - medium=medium, - hits=after[0] - before[0], - misses=after[1] - before[1], - ) - - if inspect.iscoroutinefunction(function): - - @wraps(function) - async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any: - object_args = cast(tuple[object, ...], args) - object_kwargs = cast(dict[str, object], kwargs) - trace, medium, key, before = prepare(object_args, object_kwargs) - try: - if trace is None: - return await function(*args, **kwargs) - with _tracked_span( - trace, - f"property.{_public_property_key(key)}", - property_key=key, - property_operation=operation, - ): - return await function(*args, **kwargs) - finally: - finish_cache(trace, medium, key, before) - - _copy_cache_api(function, async_wrapper) - return cast(Callable[_P, _R], async_wrapper) - - @wraps(function) - def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: - object_args = cast(tuple[object, ...], args) - object_kwargs = cast(dict[str, object], kwargs) - trace, medium, key, before = prepare(object_args, object_kwargs) - try: - if trace is None: - return function(*args, **kwargs) - with _tracked_span( - trace, - f"property.{_public_property_key(key)}", - property_key=key, - property_operation=operation, - ): - return function(*args, **kwargs) - finally: - finish_cache(trace, medium, key, before) - - _copy_cache_api(function, wrapper) - return wrapper - - return decorate - - -def record_property_iterations( - operation: str, - iterations: int, - converged: bool, -) -> None: - """Record inverse-property solver iterations in audit mode.""" - trace = _CURRENT_TRACE.get() - if trace is None or trace.mode != "audit": + if trace is None or not _mode_enabled(minimum): + yield return - for frame in reversed(_ACTIVE_SPANS.get()): - if ( - frame.property_key is not None - and frame.property_operation == operation - ): - layer, medium, _unused_operation = frame.property_key.split("|", 2) - trace._record_iterations( - frame.property_key, - operation=operation, - layer=layer, - medium=medium, - iterations=iterations, - converged=converged, - ) - return - key = f"semantic|unknown|{operation}" - trace._record_iterations( - key, - operation=operation, - layer="semantic", - medium="unknown", - iterations=iterations, - converged=converged, - ) - - -def record_property_cache(operation: str, *, hit: bool) -> None: - """Record one run-local property-cache lookup in audit mode.""" - - trace = _CURRENT_TRACE.get() - if trace is None or trace.mode != "audit": - return - for frame in reversed(_ACTIVE_SPANS.get()): - if ( - frame.property_key is not None - and frame.property_operation == operation - ): - layer, medium, _unused_operation = frame.property_key.split("|", 2) - trace._record_cache( - frame.property_key, - operation=operation, - layer=layer, - medium=medium, - hits=1 if hit else 0, - misses=0 if hit else 1, - ) - return - - -__all__ = [ - "PROFILE_MODE", - "PerformanceTrace", - "performance_span", - "profile_phase", - "profile_property", - "profile_run", - "record_property_cache", - "record_property_iterations", -] + with _tracked_span(trace, name): + yield diff --git a/app/simulation/physical_state_v21.py b/app/simulation/physical_state_v21.py deleted file mode 100644 index 3463df4..0000000 --- a/app/simulation/physical_state_v21.py +++ /dev/null @@ -1,1496 +0,0 @@ -"""Physical-state-v2.1 projection and AMESim-baseline protocol for test_mql. - -AMESim simulation values are the authoritative physical baseline. Every -runtime evaluation compares the current projected values directly with that -baseline and reports the relative error when the AMESim denominator is -non-zero. Frozen Python values are retained only as implementation and -determinism evidence; they are not a physical-correctness authority. -""" - -from __future__ import annotations - -import argparse -from collections.abc import Mapping, Sequence -from datetime import UTC, datetime -import hashlib -import json -import math -import os -from pathlib import Path -import tarfile -from typing import Any - - -PHYSICAL_STATE_V21_ID = "physical-state-v2.1" -PHYSICAL_STATE_V21_SCHEMA_VERSION = 1 -PHYSICAL_STATE_V21_GOLDEN_SCHEMA_VERSION = 1 -AMESIM_REFERENCE_PRESSURE_PA = 101_300.0 -REPOSITORY_ROOT = Path(__file__).resolve().parents[2] -DEFAULT_AMESIM_ARCHIVE = REPOSITORY_ROOT / "AmesimModels" / "test_mql.ame" - -_PNCH_PRESSURE = "amesim_pnch012_8.p" -_LINE_PRESSURE = "amesim_pnl0001_20.p" -_LINE_FLOW = "amesim_pnl0001_20.port_1.m_flow" -_ORIFICE_FLOW = "amesim_pnvo001_5.port_2.m_flow" -_ORIFICE_OPENING = "amesim_pnvo001_5.xv" -_NODE_FLOWS = tuple( - f"amesim_p4node2_8.port_{index}.m_flow" for index in range(1, 5) -) -_ORIFICE_BALANCE_FLOWS = ( - "amesim_pnvo001_5.port_2.m_flow", - "amesim_pnvo001_5.port_3.m_flow", -) -_CONNECTION_BALANCE_FLOWS = ( - "amesim_pnl0001_20.port_1.m_flow", - "amesim_pnch012_8.port_1.m_flow", -) -_MASS_9_MODE_SOURCES = ( - "amesim_mecmas21_9.x", - "amesim_mecmas21_9.v", - "amesim_mecmas21_9.port_1.f", - "amesim_mecmas21_9.port_2.f", -) -_MASS_10_MODE_SOURCES = ( - "amesim_mecmas21_10.x", - "amesim_mecmas21_10.v", - "amesim_mecmas21_10.port_1.f", - "amesim_mecmas21_10.port_2.f", -) - - -class PhysicalStateV21Error(ValueError): - """Raised when a projection or golden is incomplete or unauditable.""" - - -def _sha256(payload: bytes) -> str: - return hashlib.sha256(payload).hexdigest() - - -def _canonical_sha256(value: object) -> str: - return _sha256( - json.dumps( - value, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - ) - - -def _valid_sha256(value: object) -> bool: - return ( - isinstance(value, str) - and len(value) == 64 - and all(character in "0123456789abcdef" for character in value) - ) - - -def _portable_path(path: Path | str) -> str: - """Keep repository provenance relocatable while preserving external paths.""" - - resolved = Path(path).resolve() - try: - return resolved.relative_to(REPOSITORY_ROOT).as_posix() - except ValueError: - return str(resolved) - - -def _finite(value: object, *, field: str) -> float: - if not isinstance(value, (int, float)) or not math.isfinite(float(value)): - raise PhysicalStateV21Error(f"{field} must be a finite number.") - return float(value) - - -def _metadata_and_series( - result: Mapping[str, object], -) -> tuple[dict[str, Mapping[str, object]], Mapping[str, object]]: - variables = result.get("variables") - series = result.get("series") - if not isinstance(variables, list) or not isinstance(series, Mapping): - raise PhysicalStateV21Error("Simulation result requires variables and series.") - metadata: dict[str, Mapping[str, object]] = {} - for index, variable in enumerate(variables): - if not isinstance(variable, Mapping) or not isinstance(variable.get("key"), str): - raise PhysicalStateV21Error(f"Variable {index} has no string key.") - key = str(variable["key"]) - if key in metadata: - raise PhysicalStateV21Error(f"Duplicate result variable key: {key}.") - metadata[key] = variable - return metadata, series - - -def _require_source( - metadata: Mapping[str, Mapping[str, object]], - series: Mapping[str, object], - key: str, - quantity: str, - unit: str, -) -> None: - variable = metadata.get(key) - if variable is None: - raise PhysicalStateV21Error(f"Required v2.1 source is missing: {key}.") - if variable.get("quantity") != quantity or variable.get("unit") != unit: - raise PhysicalStateV21Error( - f"Unexpected metadata for {key}: quantity={variable.get('quantity')!r}, " - f"unit={variable.get('unit')!r}; expected {quantity!r}, {unit!r}." - ) - values = series.get(key) - if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): - raise PhysicalStateV21Error(f"Required v2.1 series is missing: {key}.") - - -def _projection( - key: str, - category: str, - unit: str, - sources: Sequence[str], - python_formula: str, - amesim: Mapping[str, object], - *, - relative: float, - absolute: float, -) -> dict[str, object]: - return { - "key": key, - "category": category, - "unit": unit, - "python": {"sourceKeys": list(sources), "formula": python_formula}, - "amesim": dict(amesim), - "tolerance": {"relative": relative, "absolute": absolute}, - } - - -def _projection_layout( - result: Mapping[str, object], -) -> tuple[dict[str, object], tuple[str, ...]]: - metadata, series = _metadata_and_series(result) - for key in (_PNCH_PRESSURE, _LINE_PRESSURE): - _require_source(metadata, series, key, "pressure", "Pa") - for key in set( - ( - _LINE_FLOW, - _ORIFICE_FLOW, - *_NODE_FLOWS, - *_ORIFICE_BALANCE_FLOWS, - *_CONNECTION_BALANCE_FLOWS, - ) - ): - _require_source(metadata, series, key, "mass_flow", "kg/s") - _require_source(metadata, series, _ORIFICE_OPENING, "dimensionless", "") - for key in (_MASS_9_MODE_SOURCES[0], _MASS_10_MODE_SOURCES[0]): - _require_source(metadata, series, key, "length", "m") - for key in (_MASS_9_MODE_SOURCES[1], _MASS_10_MODE_SOURCES[1]): - _require_source(metadata, series, key, "velocity", "m/s") - for key in (*_MASS_9_MODE_SOURCES[2:], *_MASS_10_MODE_SOURCES[2:]): - _require_source(metadata, series, key, "force", "N") - - stored_mass_keys = tuple( - sorted( - key - for key, variable in metadata.items() - if variable.get("category") == "state" - and variable.get("quantity") == "mass" - and variable.get("unit") == "kg" - ) - ) - if not stored_mass_keys: - raise PhysicalStateV21Error("No stored gas-mass states were found.") - for key in stored_mass_keys: - values = series.get(key) - if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): - raise PhysicalStateV21Error(f"Stored mass series is missing: {key}.") - - gauge_to_absolute = { - "sourceUnit": "Pa (gauge)", - "targetUnit": "Pa (absolute)", - "formula": "target = source + 101300", - "scale": 1.0, - "offset": AMESIM_REFERENCE_PRESSURE_PA, - } - g_s_to_generic = { - "sourceUnit": "g/s (AMESim component orientation)", - "targetUnit": "kg/s (positive into generic component)", - "formula": "target = -source * 1e-3", - "scale": -1.0e-3, - "offset": 0.0, - } - projections = [ - _projection( - "pressure.pnch012_8.absolute", "pressure", "Pa", [_PNCH_PRESSURE], - "identity", {"dataPaths": ["press@pn_c1_8"], **gauge_to_absolute}, - relative=2.0e-4, absolute=1.0e-3, - ), - _projection( - "pressure.pnl0001_20.absolute", "pressure", "Pa", [_LINE_PRESSURE], - "identity", {"dataPaths": ["p2@pneumatic_69"], **gauge_to_absolute}, - relative=2.0e-4, absolute=1.0e-3, - ), - _projection( - "massFlow.pnl0001_20.port_1.intoComponent", "massFlow", "kg/s", - [_LINE_FLOW], "identity", {"dataPaths": ["dm1@pneumatic_69"], **g_s_to_generic}, - relative=2.0e-4, absolute=1.0e-9, - ), - _projection( - "massFlow.pnvo001_5.port_2.intoComponent", "massFlow", "kg/s", - [_ORIFICE_FLOW], "identity", {"dataPaths": ["dm2@pn_morifice_1"], **g_s_to_generic}, - relative=2.0e-4, absolute=1.0e-9, - ), - _projection( - "conservation.p4node2_8.massBalance", "conservation", "kg/s", - _NODE_FLOWS, "sum", - {"dataPaths": [], "note": "AMESim archive has no complete saved four-port tuple."}, - relative=0.0, absolute=1.0e-9, - ), - _projection( - "conservation.pnvo001_5.massBalance", "conservation", "kg/s", - _ORIFICE_BALANCE_FLOWS, "sum", - { - "dataPaths": ["dm2@pn_morifice_1", "dm3@pn_morifice_1"], - "sourceUnit": "g/s", "targetUnit": "kg/s", - "formula": "target = -(source1 + source2) * 1e-3", - }, - relative=0.0, absolute=1.0e-9, - ), - _projection( - "conservation.pnl0001_20_pnch012_8.connectionMassBalance", - "conservation", "kg/s", _CONNECTION_BALANCE_FLOWS, "sum", - {"dataPaths": [], "note": "PNCH012 port flow is not saved; local connector contract is authoritative."}, - relative=0.0, absolute=1.0e-9, - ), - _projection( - "conservation.totalStoredGasMass", "conservation", "kg", - stored_mass_keys, "sum", - { - "dataPathSelection": "units == 'g' and signal starts with 'mgas'", - "sourceUnit": "g", "targetUnit": "kg", - "formula": "target = sum(sources) * 1e-3", - }, - relative=2.0e-4, absolute=1.0e-9, - ), - _projection( - "discrete.pnvo001_5.openMode", "discreteMode", "1", - [_ORIFICE_OPENING], "1 if opening >= 0.5 else 0", - { - "dataPaths": ["xv@pn_morifice_1"], "sourceUnit": "1", - "targetUnit": "mode code {0,1}", "formula": "1 if source >= 0.5 else 0", - }, - relative=0.0, absolute=0.0, - ), - _projection( - "discrete.mecmas21_9.endstopMode", "discreteMode", "1", - _MASS_9_MODE_SOURCES, - "-1 lower, 0 free, +1 upper using boundary and net-force direction", - { - "dataPaths": ["x1@mass_friction_endstops_19", "v1@mass_friction_endstops_19"], - "lowerBound": -0.72, "upperBound": 0.0, - "note": "No saved AMESim endstop code; reference is boundary occupancy.", - }, - relative=0.0, absolute=0.0, - ), - _projection( - "discrete.mecmas21_10.endstopMode", "discreteMode", "1", - _MASS_10_MODE_SOURCES, - "-1 lower, 0 free, +1 upper using boundary and net-force direction", - { - "dataPaths": ["x1@mass_friction_endstops_18", "v1@mass_friction_endstops_18"], - "lowerBound": 0.0, "upperBound": 0.37, - "note": "No saved AMESim endstop code; reference is boundary occupancy.", - }, - relative=0.0, absolute=0.0, - ), - ] - keys = [str(projection["key"]) for projection in projections] - hash_payload = {"projectionKeys": keys, "projections": projections} - return { - **hash_payload, - "projectionLayoutSha256": _canonical_sha256(hash_payload), - }, stored_mass_keys - - -def _value_at(series: Mapping[str, object], key: str, index: int) -> float: - values = series[key] - assert isinstance(values, Sequence) - if index >= len(values): - raise PhysicalStateV21Error(f"Series {key!r} is short at index {index}.") - return _finite(values[index], field=f"series.{key}[{index}]") - - -def _endstop_mode( - position: float, - velocity: float, - force_1: float, - force_2: float, - lower: float, - upper: float, -) -> float: - x_tol = 1.0e-12 * max(abs(position), abs(lower), abs(upper), 1.0) - v_tol = 1.0e-12 * max(abs(velocity), 1.0) - net_force = force_1 + force_2 - if position <= lower + x_tol and velocity <= v_tol and net_force <= 0.0: - return -1.0 - if position >= upper - x_tol and velocity >= -v_tol and net_force >= 0.0: - return 1.0 - return 0.0 - - -def _project_row( - series: Mapping[str, object], - index: int, - stored_mass_keys: Sequence[str], -) -> dict[str, float]: - mass_9 = [_value_at(series, key, index) for key in _MASS_9_MODE_SOURCES] - mass_10 = [_value_at(series, key, index) for key in _MASS_10_MODE_SOURCES] - return { - "pressure.pnch012_8.absolute": _value_at(series, _PNCH_PRESSURE, index), - "pressure.pnl0001_20.absolute": _value_at(series, _LINE_PRESSURE, index), - "massFlow.pnl0001_20.port_1.intoComponent": _value_at(series, _LINE_FLOW, index), - "massFlow.pnvo001_5.port_2.intoComponent": _value_at(series, _ORIFICE_FLOW, index), - "conservation.p4node2_8.massBalance": sum(_value_at(series, key, index) for key in _NODE_FLOWS), - "conservation.pnvo001_5.massBalance": sum(_value_at(series, key, index) for key in _ORIFICE_BALANCE_FLOWS), - "conservation.pnl0001_20_pnch012_8.connectionMassBalance": sum( - _value_at(series, key, index) for key in _CONNECTION_BALANCE_FLOWS - ), - "conservation.totalStoredGasMass": sum(_value_at(series, key, index) for key in stored_mass_keys), - "discrete.pnvo001_5.openMode": float(_value_at(series, _ORIFICE_OPENING, index) >= 0.5), - "discrete.mecmas21_9.endstopMode": _endstop_mode(*mass_9, -0.72, 0.0), - "discrete.mecmas21_10.endstopMode": _endstop_mode(*mass_10, 0.0, 0.37), - } - - -def project_physical_state_v21( - result: Mapping[str, object], - *, - checkpoint_times: Sequence[float], - sample_step: float, -) -> dict[str, object]: - """Project pressure, flow, conservation, and discrete-mode checkpoints.""" - - if not math.isfinite(float(sample_step)) or float(sample_step) <= 0.0: - raise PhysicalStateV21Error("sample_step must be finite and positive.") - layout, stored_mass_keys = _projection_layout(result) - _metadata, series = _metadata_and_series(result) - raw_times = series.get("time") - if not isinstance(raw_times, Sequence) or isinstance(raw_times, (str, bytes)): - raise PhysicalStateV21Error("Simulation result requires a time series.") - times = [_finite(value, field=f"series.time[{index}]") for index, value in enumerate(raw_times)] - if not times or any(first >= second for first, second in zip(times, times[1:])): - raise PhysicalStateV21Error("Time series must be non-empty and strictly increasing.") - keys = layout["projectionKeys"] - assert isinstance(keys, list) - tolerance = max(1.0e-12, 0.51 * float(sample_step)) - checkpoints: list[dict[str, object]] = [] - for raw_requested in checkpoint_times: - requested = _finite(raw_requested, field="checkpoint time") - index = min(range(len(times)), key=lambda candidate: abs(times[candidate] - requested)) - actual = times[index] - if abs(actual - requested) > tolerance: - checkpoints.append({"requestedTime": requested, "available": False, "nearestTime": actual}) - continue - values = _project_row(series, index, stored_mass_keys) - if list(values) != keys: - raise PhysicalStateV21Error("Internal v2.1 projection layout drifted.") - checkpoints.append( - {"requestedTime": requested, "actualTime": actual, "available": True, "values": values} - ) - return { - "schemaVersion": PHYSICAL_STATE_V21_SCHEMA_VERSION, - "id": PHYSICAL_STATE_V21_ID, - "projectionCategories": ["pressure", "massFlow", "conservation", "discreteMode"], - "layout": layout, - "checkpoints": checkpoints, - "comparisonMode": "amesimAuthoritativeBaselineWithLocalInvariants", - } - - -def physical_state_v21_applicable(result: Mapping[str, object]) -> bool: - """Return true only for results carrying the test_mql v2.1 signature.""" - - series = result.get("series") - return isinstance(series, Mapping) and _PNCH_PRESSURE in series - - -def _amesim_value(results: Any, data_path: str, time_s: float) -> float: - from app.simulation.reporting.test_mql_comparison import interpolate_series_value - - if data_path not in results.series_by_data_path: - raise PhysicalStateV21Error(f"AMESim is missing Data_Path {data_path!r}.") - return interpolate_series_value(results.times, results.series(data_path), time_s) - - -def _amesim_boundary_mode( - results: Any, - alias: str, - time_s: float, - lower: float, - upper: float, -) -> float: - position = _amesim_value(results, f"x1@{alias}", time_s) - velocity = _amesim_value(results, f"v1@{alias}", time_s) - tolerance = 1.0e-10 * max(abs(position), abs(lower), abs(upper), 1.0) - if abs(velocity) <= 1.0e-10 and position <= lower + tolerance: - return -1.0 - if abs(velocity) <= 1.0e-10 and position >= upper - tolerance: - return 1.0 - return 0.0 - - -def _archive_provenance(archive_path: Path) -> dict[str, object]: - archive_payload = archive_path.read_bytes() - with tarfile.open(archive_path) as archive: - names = set(archive.getnames()) - preferred = ("test_mql_.var", "test_mql_.results") - if all(name in names for name in preferred): - members = preferred - else: - pairs = sorted( - (name, f"{name[:-4]}.results") - for name in names - if name.endswith(".var") and f"{name[:-4]}.results" in names - ) - if len(pairs) != 1: - raise PhysicalStateV21Error("Could not resolve one AMESim result pair.") - members = pairs[0] - member_records = [] - for name in members: - extracted = archive.extractfile(name) - if extracted is None: - raise PhysicalStateV21Error(f"AMESim member is missing: {name}.") - payload = extracted.read() - member_records.append( - {"name": name, "bytes": len(payload), "sha256": _sha256(payload)} - ) - return { - "archivePath": _portable_path(archive_path), - "archiveBytes": len(archive_payload), - "archiveSha256": _sha256(archive_payload), - "members": member_records, - "referencePressurePa": AMESIM_REFERENCE_PRESSURE_PA, - "role": "external calibration reference; not an equality claim", - } - - -def build_amesim_reference( - archive_path: Path | str, - *, - checkpoint_times: Sequence[float], -) -> dict[str, object]: - """Return transformed AMESim values with archive/member provenance.""" - - # Keep the 132-state legacy model catalog out of benchmark worker startup; - # it is needed only by this explicit AMESim-reference operation. - from app.simulation.reporting.amesim_results import load_test_mql_amesim_results - from app.simulation.reporting.test_mql_variables import ( - build_test_mql_variable_catalog, - ) - - path = Path(archive_path).resolve() - times = tuple(_finite(value, field="AMESim checkpoint") for value in checkpoint_times) - results = load_test_mql_amesim_results(path, time_stop_s=max(times, default=0.0)) - variable_catalog = build_test_mql_variable_catalog(results) - mass_paths = tuple( - variable.data_path - for variable in variable_catalog.variables - if variable.units == "g" and variable.signal_name.startswith("mgas") - ) - if not mass_paths: - raise PhysicalStateV21Error("AMESim archive contains no stored gas mass.") - checkpoints: list[dict[str, object]] = [] - for time_s in times: - dm2 = _amesim_value(results, "dm2@pn_morifice_1", time_s) - dm3 = _amesim_value(results, "dm3@pn_morifice_1", time_s) - values: dict[str, float | None] = { - "pressure.pnch012_8.absolute": _amesim_value( - results, "press@pn_c1_8", time_s - ) + AMESIM_REFERENCE_PRESSURE_PA, - "pressure.pnl0001_20.absolute": _amesim_value( - results, "p2@pneumatic_69", time_s - ) + AMESIM_REFERENCE_PRESSURE_PA, - "massFlow.pnl0001_20.port_1.intoComponent": -1.0e-3 - * _amesim_value(results, "dm1@pneumatic_69", time_s), - "massFlow.pnvo001_5.port_2.intoComponent": -1.0e-3 * dm2, - "conservation.p4node2_8.massBalance": None, - "conservation.pnvo001_5.massBalance": -1.0e-3 * (dm2 + dm3), - "conservation.pnl0001_20_pnch012_8.connectionMassBalance": None, - "conservation.totalStoredGasMass": 1.0e-3 - * sum(_amesim_value(results, data_path, time_s) for data_path in mass_paths), - "discrete.pnvo001_5.openMode": float( - _amesim_value(results, "xv@pn_morifice_1", time_s) >= 0.5 - ), - # Generic mass_9 is the 90,000 kg AMESim instance 10 (alias _19). - "discrete.mecmas21_9.endstopMode": _amesim_boundary_mode( - results, "mass_friction_endstops_19", time_s, -0.72, 0.0 - ), - # Generic mass_10 is the 170,000 kg AMESim instance 9 (alias _18). - "discrete.mecmas21_10.endstopMode": _amesim_boundary_mode( - results, "mass_friction_endstops_18", time_s, 0.0, 0.37 - ), - } - checkpoints.append({"requestedTime": time_s, "values": values}) - return { - "schemaVersion": 1, - "provenance": _archive_provenance(path), - "storedMassDataPaths": list(mass_paths), - "checkpoints": checkpoints, - } - - -def _validate_contract( - contract: Mapping[str, object], - *, - require_available: bool = True, -) -> tuple[list[str], list[Mapping[str, object]]]: - if contract.get("schemaVersion") != PHYSICAL_STATE_V21_SCHEMA_VERSION: - raise PhysicalStateV21Error("Unsupported v2.1 contract schemaVersion.") - if contract.get("id") != PHYSICAL_STATE_V21_ID: - raise PhysicalStateV21Error("Unexpected v2.1 contract id.") - layout = contract.get("layout") - if not isinstance(layout, Mapping): - raise PhysicalStateV21Error("v2.1 layout must be an object.") - keys = layout.get("projectionKeys") - projections = layout.get("projections") - if ( - not isinstance(keys, list) - or not keys - or not all(isinstance(key, str) and key for key in keys) - or len(set(keys)) != len(keys) - ): - raise PhysicalStateV21Error("Projection keys must be unique strings.") - if not isinstance(projections, list) or len(projections) != len(keys): - raise PhysicalStateV21Error("Projection metadata layout is invalid.") - typed: list[Mapping[str, object]] = [] - for key, projection in zip(keys, projections): - if not isinstance(projection, Mapping) or projection.get("key") != key: - raise PhysicalStateV21Error("Projection metadata order is invalid.") - tolerance = projection.get("tolerance") - if not isinstance(tolerance, Mapping): - raise PhysicalStateV21Error(f"Projection {key} has no tolerance.") - for field in ("relative", "absolute"): - value = _finite(tolerance.get(field), field=f"{key}.tolerance.{field}") - if value < 0.0: - raise PhysicalStateV21Error("Projection tolerance cannot be negative.") - typed.append(projection) - hash_payload = {"projectionKeys": keys, "projections": projections} - if layout.get("projectionLayoutSha256") != _canonical_sha256(hash_payload): - raise PhysicalStateV21Error("Projection layout hash mismatch.") - checkpoints = contract.get("checkpoints") - if not isinstance(checkpoints, list) or not checkpoints: - raise PhysicalStateV21Error("v2.1 contract requires checkpoints.") - for checkpoint in checkpoints: - if not isinstance(checkpoint, Mapping): - raise PhysicalStateV21Error("v2.1 checkpoint must be an object.") - if not checkpoint.get("available"): - if require_available: - raise PhysicalStateV21Error("Every golden checkpoint must be available.") - continue - _finite(checkpoint.get("requestedTime"), field="checkpoint.requestedTime") - _finite(checkpoint.get("actualTime"), field="checkpoint.actualTime") - values = checkpoint.get("values") - if not isinstance(values, Mapping) or list(values) != keys: - raise PhysicalStateV21Error("Checkpoint values do not match layout.") - for key in keys: - _finite(values[key], field=f"checkpoint.values.{key}") - return keys, typed - - -def _case_summary( - report: Mapping[str, object], - *, - case_id: str, - lane: str, -) -> Mapping[str, object]: - cases = report.get("cases") - if not isinstance(cases, list): - raise PhysicalStateV21Error("Regression report cases must be a list.") - matches = [ - case - for case in cases - if isinstance(case, Mapping) - and case.get("caseId") == case_id - and case.get("lane") == lane - ] - if len(matches) != 1: - raise PhysicalStateV21Error( - f"Expected one {case_id!r}/{lane!r} case, found {len(matches)}." - ) - worker = matches[0].get("worker") - summary = worker.get("summary") if isinstance(worker, Mapping) else None - if not isinstance(summary, Mapping): - raise PhysicalStateV21Error("Regression case has no worker summary.") - return summary - - -def _case_contract( - report: Mapping[str, object], - *, - case_id: str, - lane: str, -) -> Mapping[str, object]: - summary = _case_summary(report, case_id=case_id, lane=lane) - contract = summary.get("physicalStateV21") - if not isinstance(contract, Mapping): - raise PhysicalStateV21Error( - "Report has no physicalStateV21 contract; regenerate it with v2.1 enabled." - ) - return contract - - -def _final_result_from_summary(summary: Mapping[str, object]) -> dict[str, object]: - """Adapt a pre-v2.1 report's final map for one honest endpoint assessment.""" - - final = summary.get("final") - simulated_until = summary.get("simulatedUntil") - if not isinstance(final, Mapping): - raise PhysicalStateV21Error("Legacy report summary has no final values.") - time_s = _finite(simulated_until, field="summary.simulatedUntil") - required = { - _PNCH_PRESSURE: ("pressure", "Pa", "thermodynamic"), - _LINE_PRESSURE: ("pressure", "Pa", "thermodynamic"), - _LINE_FLOW: ("mass_flow", "kg/s", "port"), - _ORIFICE_FLOW: ("mass_flow", "kg/s", "port"), - _ORIFICE_OPENING: ("dimensionless", "", "derived"), - **{key: ("mass_flow", "kg/s", "port") for key in _NODE_FLOWS}, - **{key: ("mass_flow", "kg/s", "port") for key in _ORIFICE_BALANCE_FLOWS}, - **{key: ("mass_flow", "kg/s", "port") for key in _CONNECTION_BALANCE_FLOWS}, - _MASS_9_MODE_SOURCES[0]: ("length", "m", "state"), - _MASS_9_MODE_SOURCES[1]: ("velocity", "m/s", "state"), - _MASS_9_MODE_SOURCES[2]: ("force", "N", "port"), - _MASS_9_MODE_SOURCES[3]: ("force", "N", "port"), - _MASS_10_MODE_SOURCES[0]: ("length", "m", "state"), - _MASS_10_MODE_SOURCES[1]: ("velocity", "m/s", "state"), - _MASS_10_MODE_SOURCES[2]: ("force", "N", "port"), - _MASS_10_MODE_SOURCES[3]: ("force", "N", "port"), - } - mass_keys = sorted( - key - for key in final - if isinstance(key, str) - and (key.endswith(".m") or key.endswith(".m1") or key.endswith(".m2")) - ) - variables = [] - series: dict[str, list[float]] = {"time": [time_s]} - for key, (quantity, unit, category) in required.items(): - if key not in final: - raise PhysicalStateV21Error(f"Legacy final map is missing {key}.") - variables.append({"key": key, "quantity": quantity, "unit": unit, "category": category}) - series[key] = [_finite(final[key], field=f"summary.final.{key}")] - for key in mass_keys: - variables.append({"key": key, "quantity": "mass", "unit": "kg", "category": "state"}) - series[key] = [_finite(final[key], field=f"summary.final.{key}")] - return {"variables": variables, "series": series} - - -def project_report_endpoint_v21( - report: Mapping[str, object], - *, - case_id: str = "0.2s", - lane: str = "production", -) -> dict[str, object]: - """Project the endpoint of a report produced before v2.1 was connected.""" - - summary = _case_summary(report, case_id=case_id, lane=lane) - result = _final_result_from_summary(summary) - time_s = float(summary["simulatedUntil"]) - return project_physical_state_v21( - result, checkpoint_times=(time_s,), sample_step=max(time_s, 1.0e-12) - ) - - -def compare_contract_to_amesim( - contract: Mapping[str, object], - amesim_reference: Mapping[str, object], -) -> dict[str, object]: - """Compare every projection with the authoritative AMESim baseline. - - AMESim may not expose every internal conservation quantity. Those rows are - retained in ``metrics`` with an unavailable baseline instead of being - silently discarded. A zero AMESim baseline has no mathematically defined - relative error, so the report records ``None`` and relies on the declared - absolute tolerance for the pass/fail decision. - """ - - keys, projections = _validate_contract(contract) - checkpoints = contract["checkpoints"] - references = amesim_reference.get("checkpoints") - if not isinstance(checkpoints, list) or not isinstance(references, list): - raise PhysicalStateV21Error("AMESim comparison checkpoints are missing.") - if len(checkpoints) != len(references): - raise PhysicalStateV21Error("AMESim checkpoint count differs from contract.") - # This is an external alignment profile, deliberately distinct from the - # tighter local non-regression tolerances embedded in the projection layout. - external_tolerance = { - "pressure": {"relative": 2.0e-3, "absolute": 1.0e-3}, - "massFlow": {"relative": 2.0e-3, "absolute": 1.0e-9}, - "conservation": {"relative": 2.0e-3, "absolute": 1.0e-9}, - "discreteMode": {"relative": 0.0, "absolute": 0.0}, - } - compared = 0 - available = 0 - unavailable = 0 - excluded = 0 - issues: list[str] = [] - metrics: list[dict[str, object]] = [] - worst: dict[str, object] | None = None - max_ratio = 0.0 - worst_relative: dict[str, object] | None = None - max_relative_error: float | None = None - max_recorded_relative_error: float | None = None - for checkpoint, reference in zip(checkpoints, references): - assert isinstance(checkpoint, Mapping) and isinstance(reference, Mapping) - actual_values = checkpoint["values"] - reference_values = reference.get("values") - assert isinstance(actual_values, Mapping) and isinstance(reference_values, Mapping) - for key, projection in zip(keys, projections): - actual = float(actual_values[key]) - requested_time = float(checkpoint["requestedTime"]) - category = str(projection["category"]) - expected = reference_values.get(key) - if expected is None: - unavailable += 1 - metrics.append( - { - "requestedTime": requested_time, - "key": key, - "category": category, - "actual": actual, - "amesimBaseline": None, - "absoluteError": None, - "relativeError": None, - "relativeErrorPercent": None, - "relativeErrorDefined": False, - "relativeErrorReason": "amesimBaselineUnavailable", - "allowedError": None, - "toleranceRatio": None, - "unboundedToleranceRatio": False, - "comparisonBasis": "localInvariantOnly", - "evaluated": False, - "passed": None, - "exclusionReason": "amesimBaselineUnavailable", - } - ) - continue - available += 1 - expected_numeric = float(expected) - tolerance = external_tolerance[category] - allowed = tolerance["absolute"] + tolerance["relative"] * abs(expected_numeric) - error = abs(actual - expected_numeric) - ratio = error / allowed if allowed > 0.0 else (0.0 if error == 0.0 else math.inf) - relative_error = ( - None if expected_numeric == 0.0 else error / abs(expected_numeric) - ) - relative_error_percent = ( - None if relative_error is None else 100.0 * relative_error - ) - near_zero_threshold = ( - tolerance["absolute"] / tolerance["relative"] - if tolerance["relative"] > 0.0 - else 0.0 - ) - if category == "discreteMode": - comparison_basis = "exactMatch" - elif expected_numeric == 0.0 or abs(expected_numeric) <= near_zero_threshold: - comparison_basis = "absoluteNearZero" - else: - comparison_basis = "mixedAbsoluteRelativeTolerance" - excluded_at_signal_jump = category == "massFlow" and math.isclose( - requested_time, 0.04, rel_tol=0.0, abs_tol=1.0e-12 - ) - metric = { - "requestedTime": requested_time, - "key": key, - "category": category, - "actual": actual, - "amesimBaseline": expected_numeric, - "absoluteError": error, - "relativeError": relative_error, - "relativeErrorPercent": relative_error_percent, - "relativeErrorDefined": relative_error is not None, - "relativeErrorReason": ( - None if relative_error is not None else "zeroAmesimBaseline" - ), - "allowedError": allowed, - "toleranceRatio": ratio if math.isfinite(ratio) else None, - "unboundedToleranceRatio": not math.isfinite(ratio), - "nearZeroThreshold": near_zero_threshold, - "comparisonBasis": comparison_basis, - } - if relative_error is not None and ( - max_recorded_relative_error is None - or relative_error > max_recorded_relative_error - ): - max_recorded_relative_error = relative_error - if excluded_at_signal_jump: - excluded += 1 - metric.update( - { - "evaluated": False, - "passed": None, - "exclusionReason": ( - "signalDiscontinuityLeftRightLimitSemanticsNotComparableAt0.04s" - ), - } - ) - metrics.append(metric) - continue - metric.update({"evaluated": True, "passed": ratio <= 1.0}) - metrics.append(metric) - compared += 1 - if ratio > max_ratio: - max_ratio = ratio - worst = metric - if relative_error is not None and ( - max_relative_error is None or relative_error > max_relative_error - ): - max_relative_error = relative_error - worst_relative = metric - if ratio > 1.0: - issues.append("amesimReferenceToleranceMismatch") - deduplicated = list(dict.fromkeys(issues)) - return { - "passed": not deduplicated, - "issues": deduplicated, - "baselineAuthority": "amesim", - "metricCount": len(metrics), - "availableBaselineValueCount": available, - "unavailableBaselineValueCount": unavailable, - "comparedValueCount": compared, - "excludedValueCount": excluded, - "maxToleranceRatio": ( - max_ratio if compared and math.isfinite(max_ratio) else None - ), - "maxToleranceRatioUnbounded": bool(compared and not math.isfinite(max_ratio)), - "worstValue": worst, - "maxRelativeError": max_relative_error, - "maxRelativeErrorPercent": ( - None if max_relative_error is None else 100.0 * max_relative_error - ), - "worstRelativeErrorValue": worst_relative, - "maxRecordedRelativeError": max_recorded_relative_error, - "maxRecordedRelativeErrorPercent": ( - None - if max_recorded_relative_error is None - else 100.0 * max_recorded_relative_error - ), - "metrics": metrics, - "toleranceProfile": { - "id": "amesim-alignment-reviewed-v1", - "byCategory": external_tolerance, - "basis": ( - "0.2% relative envelope for AMESim pressure/flow/inventory alignment; " - "conservation retains a 1e-9 absolute floor and discrete modes are exact." - ), - "exclusions": [ - { - "requestedTime": 0.04, - "category": "massFlow", - "reason": ( - "The Python and AMESim samples at the signal discontinuity may " - "represent different left/right limits. Raw errors remain recorded." - ), - } - ], - }, - "policy": ( - "AMESim simulation values are the authoritative physical baseline and " - "are compared on every run; unavailable AMESim quantities remain " - "separate local-invariant checks." - ), - } - - -def build_candidate_golden( - report_path: Path | str, - *, - archive_path: Path | str = DEFAULT_AMESIM_ARCHIVE, - case_id: str = "0.2s", - lane: str = "production", - golden_id: str | None = None, -) -> dict[str, object]: - """Build an unapproved candidate from a report containing full v2.1 data.""" - - source_path = Path(report_path).resolve() - payload = source_path.read_bytes() - try: - report = json.loads(payload) - except json.JSONDecodeError as exc: - raise PhysicalStateV21Error(f"Regression report is invalid JSON: {exc}") from exc - if not isinstance(report, Mapping): - raise PhysicalStateV21Error("Regression report must be an object.") - contract = _case_contract(report, case_id=case_id, lane=lane) - keys, _projections = _validate_contract(contract) - checkpoints = contract["checkpoints"] - assert isinstance(checkpoints, list) - times = [float(checkpoint["requestedTime"]) for checkpoint in checkpoints] - amesim = build_amesim_reference(archive_path, checkpoint_times=times) - references = amesim["checkpoints"] - assert isinstance(references, list) - source = report.get("source") - source_sha = source.get("sha256") if isinstance(source, Mapping) else None - if not isinstance(source_sha, str) or len(source_sha) != 64: - raise PhysicalStateV21Error("Report source SHA-256 is missing.") - frozen = [] - for checkpoint, reference in zip(checkpoints, references): - assert isinstance(checkpoint, Mapping) and isinstance(reference, Mapping) - values = checkpoint["values"] - reference_values = reference["values"] - assert isinstance(values, Mapping) and isinstance(reference_values, Mapping) - frozen.append( - { - "requestedTime": checkpoint["requestedTime"], - "values": [values[key] for key in keys], - "amesimReferenceValues": [reference_values[key] for key in keys], - } - ) - layout = contract["layout"] - assert isinstance(layout, Mapping) - alignment = compare_contract_to_amesim(contract, amesim) - return { - "schemaVersion": PHYSICAL_STATE_V21_GOLDEN_SCHEMA_VERSION, - "id": golden_id - or f"test_mql_8-{lane}-{case_id}-{PHYSICAL_STATE_V21_ID}-candidate", - "contractId": PHYSICAL_STATE_V21_ID, - "caseId": case_id, - "lane": lane, - "sourceXmlSha256": source_sha, - "approval": { - "status": "candidate", - "generatedAt": datetime.now(UTC).isoformat(), - "warning": "The correctness gate rejects this file until explicit approval.", - }, - "provenance": { - "localSourceReport": { - "path": _portable_path(source_path), - "bytes": len(payload), - "sha256": _sha256(payload), - "generatedAt": report.get("generatedAt"), - }, - "amesim": amesim["provenance"], - "amesimStoredMassDataPaths": amesim["storedMassDataPaths"], - "comparisonPolicy": ( - "Transformed AMESim values are the authoritative physical baseline; " - "local Python values are retained only for deterministic and " - "implementation-regression diagnostics." - ), - }, - "layout": dict(layout), - "checkpoints": frozen, - "amesimAlignmentAtGeneration": alignment, - } - - -def _exclusive_json_write(path: Path | str, value: object) -> Path: - output = Path(path).resolve() - output.parent.mkdir(parents=True, exist_ok=True) - serialized = json.dumps( - value, ensure_ascii=False, indent=2, allow_nan=False - ) + "\n" - try: - descriptor = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) - except FileExistsError as exc: - raise PhysicalStateV21Error(f"Refusing to overwrite {output}.") from exc - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(serialized) - except BaseException: - output.unlink(missing_ok=True) - raise - return output - - -def write_candidate_golden( - report_path: Path | str, - output_path: Path | str, - **kwargs: object, -) -> Path: - return _exclusive_json_write( - output_path, - build_candidate_golden(report_path, **kwargs), - ) - - -def _validate_golden( - golden: Mapping[str, object], - *, - allowed_status: str = "approved", -) -> tuple[list[str], list[Mapping[str, object]]]: - if golden.get("schemaVersion") != PHYSICAL_STATE_V21_GOLDEN_SCHEMA_VERSION: - raise PhysicalStateV21Error("Unsupported v2.1 golden schemaVersion.") - if golden.get("contractId") != PHYSICAL_STATE_V21_ID: - raise PhysicalStateV21Error("Golden contract id is invalid.") - for field in ("id", "caseId", "lane"): - if not isinstance(golden.get(field), str) or not golden[field]: - raise PhysicalStateV21Error(f"Golden {field} must be non-empty.") - if not _valid_sha256(golden.get("sourceXmlSha256")): - raise PhysicalStateV21Error("Golden source XML SHA-256 is invalid.") - approval = golden.get("approval") - if not isinstance(approval, Mapping) or approval.get("status") != allowed_status: - raise PhysicalStateV21Error( - f"Golden approval.status must be {allowed_status!r}." - ) - provenance = golden.get("provenance") - amesim = provenance.get("amesim") if isinstance(provenance, Mapping) else None - local_report = ( - provenance.get("localSourceReport") - if isinstance(provenance, Mapping) - else None - ) - if ( - not isinstance(local_report, Mapping) - or not isinstance(local_report.get("path"), str) - or not local_report["path"] - or not isinstance(local_report.get("bytes"), int) - or int(local_report["bytes"]) <= 0 - or not _valid_sha256(local_report.get("sha256")) - ): - raise PhysicalStateV21Error("Golden local report provenance is invalid.") - if ( - not isinstance(amesim, Mapping) - or not isinstance(amesim.get("archivePath"), str) - or not amesim["archivePath"] - or not isinstance(amesim.get("archiveBytes"), int) - or int(amesim["archiveBytes"]) <= 0 - or not _valid_sha256(amesim.get("archiveSha256")) - ): - raise PhysicalStateV21Error("Golden AMESim archive provenance is invalid.") - members = amesim.get("members") - if not isinstance(members, list) or len(members) != 2: - raise PhysicalStateV21Error("Golden AMESim member provenance is incomplete.") - member_names = [] - for member in members: - if ( - not isinstance(member, Mapping) - or not isinstance(member.get("name"), str) - or not _valid_sha256(member.get("sha256")) - or not isinstance(member.get("bytes"), int) - or int(member["bytes"]) <= 0 - ): - raise PhysicalStateV21Error("Golden AMESim member provenance is invalid.") - member_names.append(str(member["name"])) - if not any(name.endswith(".var") for name in member_names) or not any( - name.endswith(".results") for name in member_names - ): - raise PhysicalStateV21Error("Golden AMESim provenance lacks .var/.results members.") - layout = golden.get("layout") - keys = layout.get("projectionKeys") if isinstance(layout, Mapping) else None - checkpoints = golden.get("checkpoints") - if not isinstance(keys, list) or not isinstance(checkpoints, list) or not checkpoints: - raise PhysicalStateV21Error("Golden layout/checkpoints are incomplete.") - contract_checkpoints = [] - for checkpoint in checkpoints: - if not isinstance(checkpoint, Mapping): - raise PhysicalStateV21Error("Golden checkpoint must be an object.") - values = checkpoint.get("values") - references = checkpoint.get("amesimReferenceValues") - if not isinstance(values, list) or len(values) != len(keys): - raise PhysicalStateV21Error("Golden values have the wrong layout.") - if not isinstance(references, list) or len(references) != len(keys): - raise PhysicalStateV21Error("AMESim references have the wrong layout.") - for index, value in enumerate(values): - _finite(value, field=f"golden.values[{index}]") - for index, value in enumerate(references): - if value is not None: - _finite(value, field=f"golden.amesimReferenceValues[{index}]") - contract_checkpoints.append( - { - "requestedTime": checkpoint.get("requestedTime"), - "actualTime": checkpoint.get("requestedTime"), - "available": True, - "values": dict(zip(keys, values)), - } - ) - synthetic_contract = { - "schemaVersion": PHYSICAL_STATE_V21_SCHEMA_VERSION, - "id": PHYSICAL_STATE_V21_ID, - "layout": layout, - "checkpoints": contract_checkpoints, - } - return _validate_contract(synthetic_contract) - - -def _validate_candidate_approval( - candidate: Mapping[str, object], - keys: Sequence[str], - projections: Sequence[Mapping[str, object]], -) -> None: - """Fail closed before promoting an externally aligned local candidate.""" - - alignment = candidate.get("amesimAlignmentAtGeneration") - if not isinstance(alignment, Mapping) or alignment.get("passed") is not True: - raise PhysicalStateV21Error( - "Candidate AMESim alignment at generation must have passed." - ) - metrics = alignment.get("metrics") - if not isinstance(metrics, list): - raise PhysicalStateV21Error("Candidate AMESim alignment metrics are invalid.") - evaluated_count = 0 - for index, metric in enumerate(metrics): - if not isinstance(metric, Mapping): - raise PhysicalStateV21Error( - f"Candidate AMESim alignment metric {index} is invalid." - ) - evaluated = metric.get("evaluated") - if evaluated is True: - evaluated_count += 1 - if metric.get("passed") is not True: - raise PhysicalStateV21Error( - f"Candidate AMESim evaluated metric {index} did not pass." - ) - elif evaluated is False: - exclusion_reason = metric.get("exclusionReason") - if ( - metric.get("passed") is not None - or not isinstance(exclusion_reason, str) - or not exclusion_reason.strip() - ): - raise PhysicalStateV21Error( - f"Candidate AMESim excluded metric {index} is not auditable." - ) - else: - raise PhysicalStateV21Error( - f"Candidate AMESim alignment metric {index} lacks evaluated status." - ) - if evaluated_count <= 0 or alignment.get("comparedValueCount") != evaluated_count: - raise PhysicalStateV21Error( - "Candidate AMESim comparedValueCount does not match evaluated metrics." - ) - - residual_indices: list[tuple[int, str, float]] = [] - for index, (key, projection) in enumerate(zip(keys, projections)): - if ( - projection.get("category") != "conservation" - or projection.get("unit") != "kg/s" - ): - continue - tolerance = projection.get("tolerance") - assert isinstance(tolerance, Mapping) - absolute = _finite( - tolerance.get("absolute"), field=f"{key}.tolerance.absolute" - ) - residual_indices.append((index, key, absolute)) - checkpoints = candidate.get("checkpoints") - assert isinstance(checkpoints, list) - for checkpoint_index, checkpoint in enumerate(checkpoints): - assert isinstance(checkpoint, Mapping) - values = checkpoint.get("values") - assert isinstance(values, list) - for value_index, key, absolute in residual_indices: - residual = abs( - _finite( - values[value_index], - field=f"golden.checkpoints[{checkpoint_index}].{key}", - ) - ) - if residual > absolute: - raise PhysicalStateV21Error( - f"Candidate local conservation residual {key} at checkpoint " - f"{checkpoint_index} is {residual}, exceeding {absolute}." - ) - - -def approve_candidate_golden( - candidate_path: Path | str, - output_path: Path | str, - *, - expected_candidate_sha256: str, - reviewed_by: str, - note: str, -) -> Path: - """Approve an exact candidate hash into a new file, never in place.""" - - path = Path(candidate_path).resolve() - payload = path.read_bytes() - actual_sha = _sha256(payload) - if actual_sha != expected_candidate_sha256: - raise PhysicalStateV21Error( - f"Candidate SHA mismatch: expected {expected_candidate_sha256}, got {actual_sha}." - ) - candidate = json.loads(payload) - if not isinstance(candidate, dict): - raise PhysicalStateV21Error("Candidate must be an object.") - keys, projections = _validate_golden(candidate, allowed_status="candidate") - _validate_candidate_approval(candidate, keys, projections) - if not reviewed_by.strip() or not note.strip(): - raise PhysicalStateV21Error("Approval requires reviewer and note.") - approved = dict(candidate) - approved["approval"] = { - "status": "approved", - "approvedAt": datetime.now(UTC).isoformat(), - "reviewedBy": reviewed_by.strip(), - "note": note.strip(), - "candidatePath": _portable_path(path), - "candidateSha256": actual_sha, - } - return _exclusive_json_write(output_path, approved) - - -def load_approved_golden(path: Path | str) -> dict[str, object]: - golden_path = Path(path).resolve() - payload = golden_path.read_bytes() - try: - golden = json.loads(payload) - except json.JSONDecodeError as exc: - raise PhysicalStateV21Error(f"Golden is invalid JSON: {exc}") from exc - if not isinstance(golden, dict): - raise PhysicalStateV21Error("Golden must be an object.") - _validate_golden(golden) - golden["_path"] = str(golden_path) - golden["_sha256"] = _sha256(payload) - return golden - - -def evaluate_physical_state_v21( - contract: Mapping[str, object], - golden: Mapping[str, object], -) -> dict[str, object]: - """Evaluate a v2.1 contract against frozen AMESim reference values. - - The approved artifact pins AMESim provenance, layout, checkpoint times, and - transformed AMESim values. Its frozen Python values are intentionally not - used as the physical baseline. - """ - - keys, projections = _validate_contract(contract) - golden_keys, _ = _validate_golden(golden) - if keys != golden_keys: - return { - "passed": False, - "issues": ["physicalStateV21LayoutMismatch"], - "comparedValueCount": 0, - } - actual_checkpoints = contract["checkpoints"] - expected_checkpoints = golden["checkpoints"] - assert isinstance(actual_checkpoints, list) and isinstance(expected_checkpoints, list) - if len(actual_checkpoints) != len(expected_checkpoints): - return { - "passed": False, - "issues": ["physicalStateV21CheckpointCountMismatch"], - "comparedValueCount": 0, - } - issues: list[str] = [] - reference_checkpoints: list[dict[str, object]] = [] - invariant_metrics: list[dict[str, object]] = [] - invariant_max_ratio = 0.0 - invariant_worst: dict[str, object] | None = None - for actual_checkpoint, expected_checkpoint in zip(actual_checkpoints, expected_checkpoints): - assert isinstance(actual_checkpoint, Mapping) and isinstance(expected_checkpoint, Mapping) - requested = float(expected_checkpoint["requestedTime"]) - if not math.isclose( - float(actual_checkpoint["requestedTime"]), - requested, - rel_tol=0.0, - abs_tol=1.0e-12, - ) or not math.isclose( - float(actual_checkpoint["actualTime"]), - requested, - rel_tol=0.0, - abs_tol=1.0e-12, - ): - issues.append("physicalStateV21CheckpointTimeMismatch") - actual_values = actual_checkpoint["values"] - amesim_values = expected_checkpoint["amesimReferenceValues"] - assert isinstance(actual_values, Mapping) and isinstance(amesim_values, list) - reference_values = dict(zip(keys, amesim_values)) - reference_checkpoints.append( - {"requestedTime": requested, "values": reference_values} - ) - for key, projection, amesim_baseline in zip( - keys, projections, amesim_values - ): - if amesim_baseline is not None: - continue - actual = float(actual_values[key]) - tolerance = projection["tolerance"] - assert isinstance(tolerance, Mapping) - allowed = float(tolerance["absolute"]) - error = abs(actual) - ratio = error / allowed if allowed > 0.0 else (0.0 if error == 0.0 else math.inf) - metric = { - "requestedTime": requested, - "key": key, - "category": projection["category"], - "actual": actual, - "amesimBaseline": None, - "invariantTarget": 0.0, - "absoluteError": error, - "relativeError": None, - "relativeErrorPercent": None, - "relativeErrorDefined": False, - "relativeErrorReason": "amesimBaselineUnavailable", - "allowedError": allowed, - "toleranceRatio": ratio if math.isfinite(ratio) else None, - "unboundedToleranceRatio": not math.isfinite(ratio), - "comparisonBasis": "localInvariantAbsoluteTolerance", - "passed": ratio <= 1.0, - } - invariant_metrics.append(metric) - if ratio > invariant_max_ratio: - invariant_max_ratio = ratio - invariant_worst = metric - if ratio > 1.0: - issues.append("physicalStateV21LocalInvariantMismatch") - comparison = compare_contract_to_amesim( - contract, - {"schemaVersion": 1, "checkpoints": reference_checkpoints}, - ) - raw_comparison_issues = comparison.get("issues") - if isinstance(raw_comparison_issues, list): - issues.extend( - str(issue) - for issue in raw_comparison_issues - if isinstance(issue, str) - ) - deduplicated = list(dict.fromkeys(issues)) - provenance = golden.get("provenance") - amesim_provenance = ( - provenance.get("amesim") if isinstance(provenance, Mapping) else None - ) - result = dict(comparison) - result.update({ - "passed": not deduplicated, - "issues": deduplicated, - "baselineAuthority": "amesim", - "baselineSource": "approvedArtifact.amesimReferenceValues", - "amesimProvenance": ( - dict(amesim_provenance) - if isinstance(amesim_provenance, Mapping) - else None - ), - "pythonSnapshotRole": "determinismDiagnosticOnly", - "localInvariants": { - "passed": not any( - metric["passed"] is False for metric in invariant_metrics - ), - "metricCount": len(invariant_metrics), - "maxToleranceRatio": ( - invariant_max_ratio - if invariant_metrics and math.isfinite(invariant_max_ratio) - else None - ), - "maxToleranceRatioUnbounded": bool( - invariant_metrics and not math.isfinite(invariant_max_ratio) - ), - "worstValue": invariant_worst, - "metrics": invariant_metrics, - }, - "goldenPath": golden.get("_path"), - "goldenSha256": golden.get("_sha256"), - }) - return result - - -def assess_report_against_amesim( - report_path: Path | str, - *, - archive_path: Path | str = DEFAULT_AMESIM_ARCHIVE, - case_id: str = "0.2s", - lane: str = "production", -) -> dict[str, object]: - """Assess a full or pre-v2.1 report and expose every comparable error.""" - - path = Path(report_path).resolve() - payload = path.read_bytes() - report = json.loads(payload) - if not isinstance(report, Mapping): - raise PhysicalStateV21Error("Report must be an object.") - summary = _case_summary(report, case_id=case_id, lane=lane) - if isinstance(summary.get("physicalStateV21"), Mapping): - contract = summary["physicalStateV21"] - coverage = "fullCheckpoints" - else: - contract = project_report_endpoint_v21(report, case_id=case_id, lane=lane) - coverage = "legacyEndpointOnly" - assert isinstance(contract, Mapping) - checkpoints = contract["checkpoints"] - assert isinstance(checkpoints, list) - times = [float(checkpoint["requestedTime"]) for checkpoint in checkpoints] - reference = build_amesim_reference(archive_path, checkpoint_times=times) - comparison = compare_contract_to_amesim(contract, reference) - return { - "schemaVersion": 1, - "reportPath": str(path), - "reportSha256": _sha256(payload), - "caseId": case_id, - "lane": lane, - "coverage": coverage, - "amesimProvenance": reference["provenance"], - "comparison": comparison, - } - - -def _parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Physical-state-v2.1 local gate.") - subparsers = parser.add_subparsers(dest="command", required=True) - assess = subparsers.add_parser("assess-amesim") - assess.add_argument("--report", type=Path, required=True) - assess.add_argument("--amesim-archive", type=Path, default=DEFAULT_AMESIM_ARCHIVE) - assess.add_argument("--case", default="0.2s") - assess.add_argument("--lane", default="production") - assess.add_argument("--output", type=Path) - - candidate = subparsers.add_parser("candidate") - candidate.add_argument("--report", type=Path, required=True) - candidate.add_argument("--amesim-archive", type=Path, default=DEFAULT_AMESIM_ARCHIVE) - candidate.add_argument("--case", default="0.2s") - candidate.add_argument("--lane", default="production") - candidate.add_argument("--id") - candidate.add_argument("--output", type=Path, required=True) - - approve = subparsers.add_parser("approve") - approve.add_argument("--candidate", type=Path, required=True) - approve.add_argument("--expected-candidate-sha256", required=True) - approve.add_argument("--reviewed-by", required=True) - approve.add_argument("--note", required=True) - approve.add_argument("--output", type=Path, required=True) - - check = subparsers.add_parser("check") - check.add_argument("--report", type=Path, required=True) - check.add_argument("--golden", type=Path, required=True) - check.add_argument("--case", default="0.2s") - check.add_argument("--lane", default="production") - return parser.parse_args(argv) - - -def main(argv: Sequence[str] | None = None) -> int: - arguments = _parse_arguments(argv) - if arguments.command == "assess-amesim": - assessment = assess_report_against_amesim( - arguments.report, - archive_path=arguments.amesim_archive, - case_id=arguments.case, - lane=arguments.lane, - ) - serialized = json.dumps( - assessment, ensure_ascii=False, indent=2, allow_nan=False - ) + "\n" - if arguments.output is None: - print(serialized, end="") - else: - _exclusive_json_write(arguments.output, assessment) - print(f"Assessment written: {arguments.output.resolve()}") - return 0 if assessment["comparison"]["passed"] else 1 - if arguments.command == "candidate": - output = write_candidate_golden( - arguments.report, arguments.output, - archive_path=arguments.amesim_archive, - case_id=arguments.case, lane=arguments.lane, golden_id=arguments.id, - ) - print(f"Candidate written without approval: {output}") - print(f"sha256={_sha256(output.read_bytes())}") - return 0 - if arguments.command == "approve": - output = approve_candidate_golden( - arguments.candidate, arguments.output, - expected_candidate_sha256=arguments.expected_candidate_sha256, - reviewed_by=arguments.reviewed_by, note=arguments.note, - ) - print(f"Approved golden written: {output}") - print(f"sha256={_sha256(output.read_bytes())}") - return 0 - report = json.loads(arguments.report.read_bytes()) - contract = _case_contract(report, case_id=arguments.case, lane=arguments.lane) - evaluation = evaluate_physical_state_v21(contract, load_approved_golden(arguments.golden)) - print(json.dumps(evaluation, ensure_ascii=False, indent=2, allow_nan=False)) - return 0 if evaluation["passed"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/app/simulation/property_cache.py b/app/simulation/property_cache.py deleted file mode 100644 index 830fce2..0000000 --- a/app/simulation/property_cache.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Run-local, exact-key cache for expensive thermodynamic calculations. - -The cache is deliberately bound to one simulation through ``ContextVar``. -That keeps concurrent runs isolated and releases all cached states when the -run finishes. Keys use the original Python values with no rounding or -tolerance-based reuse that could flatten numerical residuals seen by ODE and -nonlinear solvers. -""" - -from __future__ import annotations - -from collections.abc import Callable, Generator -from contextlib import contextmanager -from contextvars import ContextVar -from dataclasses import dataclass -from functools import lru_cache, wraps -import os -from typing import ParamSpec, TypeVar - -from app.simulation.performance import PROFILE_MODE, record_property_cache - - -_P = ParamSpec("_P") -_R = TypeVar("_R") -DEFAULT_PROPERTY_CACHE_MAX_ENTRIES = 8192 - - -def _read_cache_enabled() -> bool: - raw_value = os.getenv("SIMULATIONAPP_PROPERTY_CACHE", "on").strip().lower() - if raw_value in {"", "1", "true", "yes", "on"}: - return True - if raw_value in {"0", "false", "no", "off"}: - return False - raise ValueError( - "SIMULATIONAPP_PROPERTY_CACHE must be one of: on, off, true, false, 1, 0." - ) - - -PROPERTY_CACHE_ENABLED = _read_cache_enabled() - - -@dataclass(frozen=True) -class PropertyCacheInfo: - hits: int - misses: int - max_entries_per_cache: int - cache_count: int - current_entries: int - evictions: int - - -class SimulationPropertyCache: - """Bounded C-level LRUs owned by one simulation run.""" - - def __init__(self, max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES) -> None: - if max_entries <= 0: - raise ValueError("Property cache max_entries must be positive.") - self.max_entries_per_cache = int(max_entries) - self._functions: dict[ - tuple[str, int, Callable[..., object]], - Callable[..., object], - ] = {} - self._failed_misses: dict[ - tuple[str, int, Callable[..., object]], - int, - ] = {} - self._owners: dict[int, tuple[object, int]] = {} - self._next_owner_token = 0 - - def owner_token(self, owner: object) -> int: - """Return a stable identity token and retain its owner for this run.""" - - identity = id(owner) - existing = self._owners.get(identity) - if existing is not None and existing[0] is owner: - return existing[1] - self._next_owner_token += 1 - self._owners[identity] = (owner, self._next_owner_token) - return self._next_owner_token - - def get_or_compute( - self, - operation: str, - owner: object, - function: Callable[..., _R], - args: tuple[object, ...], - kwargs: dict[str, object], - ) -> _R: - cache_key = (operation, self.owner_token(owner), function) - cached_function = self._functions.get(cache_key) - if cached_function is None: - - @lru_cache(maxsize=self.max_entries_per_cache, typed=True) - def invoke(*cached_args: object, **cached_kwargs: object) -> _R: - return function(owner, *cached_args, **cached_kwargs) - - cached_function = invoke - self._functions[cache_key] = cached_function - - if PROFILE_MODE != "audit": - try: - return cached_function(*args, **kwargs) - except Exception: - self._failed_misses[cache_key] = ( - self._failed_misses.get(cache_key, 0) + 1 - ) - raise - - before = cached_function.cache_info() # type: ignore[attr-defined] - try: - value = cached_function(*args, **kwargs) - except Exception: - self._failed_misses[cache_key] = ( - self._failed_misses.get(cache_key, 0) + 1 - ) - raise - finally: - after = cached_function.cache_info() # type: ignore[attr-defined] - hit = after.hits > before.hits - record_property_cache(operation, hit=hit) - return value - - def info(self) -> PropertyCacheInfo: - cache_infos = { - key: cached.cache_info() # type: ignore[attr-defined] - for key, cached in self._functions.items() - } - return PropertyCacheInfo( - hits=sum(info.hits for info in cache_infos.values()), - misses=sum(info.misses for info in cache_infos.values()), - max_entries_per_cache=self.max_entries_per_cache, - cache_count=len(self._functions), - current_entries=sum(info.currsize for info in cache_infos.values()), - evictions=sum( - max( - 0, - info.misses - - self._failed_misses.get(key, 0) - - info.currsize, - ) - for key, info in cache_infos.items() - ), - ) - - -_CURRENT_PROPERTY_CACHE: ContextVar[SimulationPropertyCache | None] = ContextVar( - "simulation_property_cache", - default=None, -) - - -def current_property_cache() -> SimulationPropertyCache | None: - return _CURRENT_PROPERTY_CACHE.get() - - -@contextmanager -def property_cache_run( - *, - max_entries: int = DEFAULT_PROPERTY_CACHE_MAX_ENTRIES, -) -> Generator[SimulationPropertyCache | None, None, None]: - """Bind a fresh cache to one top-level simulation run. - - Nested uses reuse the existing cache so lower-level simulation helpers can - safely opt in without replacing the cache created by the API entry point. - """ - - existing = _CURRENT_PROPERTY_CACHE.get() - if existing is not None: - yield existing - return - if not PROPERTY_CACHE_ENABLED: - yield None - return - - cache = SimulationPropertyCache(max_entries=max_entries) - token = _CURRENT_PROPERTY_CACHE.set(cache) - try: - yield cache - finally: - _CURRENT_PROPERTY_CACHE.reset(token) - - -def cache_property_calculation( - operation: str, -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: - """Cache one pure property calculation with hashable arguments per run.""" - - def decorate(function: Callable[_P, _R]) -> Callable[_P, _R]: - if not PROPERTY_CACHE_ENABLED: - return function - - @wraps(function) - def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: - cache = _CURRENT_PROPERTY_CACHE.get() - if cache is None: - return function(*args, **kwargs) - owner = args[0] if args else function - return cache.get_or_compute( - operation, - owner, - function, - tuple(args[1:] if args else ()), - dict(kwargs), - ) - - return wrapper - - return decorate - - -def with_property_cache(function: Callable[_P, _R]) -> Callable[_P, _R]: - """Ensure a simulation entry point has a run-local cache.""" - - if not PROPERTY_CACHE_ENABLED: - return function - - @wraps(function) - def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: - with property_cache_run(): - return function(*args, **kwargs) - - return wrapper - - -__all__ = [ - "DEFAULT_PROPERTY_CACHE_MAX_ENTRIES", - "PROPERTY_CACHE_ENABLED", - "PropertyCacheInfo", - "SimulationPropertyCache", - "cache_property_calculation", - "current_property_cache", - "property_cache_run", - "with_property_cache", -] diff --git a/app/simulation/reporting/__init__.py b/app/simulation/reporting/__init__.py index 3196560..7ef452f 100644 --- a/app/simulation/reporting/__init__.py +++ b/app/simulation/reporting/__init__.py @@ -1,23 +1 @@ -from app.simulation.reporting.testmodel_outputs import ( - COMPARISON_KEYS, - MODELICA_COMPARISON_COLUMNS, - PRIMARY_KEYS, - TestModelArtifacts, - export_testmodel_artifacts, - format_testmodel_run_report, - load_modelica_series, - write_testmodel_run_report, - write_modelica_comparison, -) - -__all__ = [ - "COMPARISON_KEYS", - "MODELICA_COMPARISON_COLUMNS", - "PRIMARY_KEYS", - "TestModelArtifacts", - "export_testmodel_artifacts", - "format_testmodel_run_report", - "load_modelica_series", - "write_testmodel_run_report", - "write_modelica_comparison", -] +"""Readers for external simulation results.""" diff --git a/app/simulation/reporting/pnl0002_replay.py b/app/simulation/reporting/pnl0002_replay.py deleted file mode 100644 index 29806b4..0000000 --- a/app/simulation/reporting/pnl0002_replay.py +++ /dev/null @@ -1,263 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping - -from app.simulation.components.amesim.flow.pipes import AmesimPnl0002 -from app.simulation.reporting.amesim_results import ( - AmesimResults, - load_test_mql_amesim_results, -) - - -@dataclass(frozen=True, slots=True) -class Pnl0002ReplayPaths: - """AMESim data paths needed to replay one PNL0002 resistance.""" - - center_pressure: str - center_temperature: str - port_1_pressure: str - port_1_temperature: str - port_1_mass_flow: str - port_2_pressure: str - port_2_temperature: str - port_2_mass_flow: str - reynolds: str - friction_factor: str - - -PNL83_REPLAY_PATHS = Pnl0002ReplayPaths( - center_pressure="pctr@pneumatic_83", - center_temperature="tctr@pneumatic_83", - port_1_pressure="press1@pnnode4_16", - port_1_temperature="temp1@pnnode4_16", - port_1_mass_flow="dm1@pneumatic_83", - port_2_pressure="press3@pnnode4_17", - port_2_temperature="temp3@pnnode4_17", - port_2_mass_flow="dm2@pneumatic_83", - reynolds="re@pneumatic_83", - friction_factor="ff@pneumatic_83", -) - - -def _required_series( - results: AmesimResults, - path: str, -) -> tuple[float, ...]: - try: - values = results.series(path) - except KeyError as exc: - raise ValueError(f"AMESim replay variable is not saved: {path}") from exc - if len(values) != len(results.times): - raise ValueError(f"AMESim replay variable has an invalid length: {path}") - return values - - -def _metric_summary( - rows: list[dict[str, float]], - key: str, -) -> dict[str, float]: - values = [float(row[key]) for row in rows] - max_index = max(range(len(values)), key=lambda index: abs(values[index])) - return { - "maxAbs": abs(values[max_index]), - "maxAbsTime": rows[max_index]["time"], - "finalSigned": values[-1], - } - - -def replay_pnl0002_amesim_states( - pipe: AmesimPnl0002, - results: AmesimResults, - paths: Pnl0002ReplayPaths, - *, - amesim_mass_flow_scale: float = -1.0e-3, -) -> dict[str, object]: - """Replay saved AMESim states through current PNL0002 flow functions. - - This is a calibration-only, no-integration calculation. It does not write - states into the pipe or alter the production simulation path. - """ - - series_by_field = { - field: _required_series(results, getattr(paths, field)) - for field in paths.__dataclass_fields__ - } - rows: list[dict[str, float]] = [] - for index, time_s in enumerate(results.times): - center_pressure = series_by_field["center_pressure"][index] - center_temperature = series_by_field["center_temperature"][index] - port_1_pressure = series_by_field["port_1_pressure"][index] - port_2_pressure = series_by_field["port_2_pressure"][index] - observed_flow_1 = ( - amesim_mass_flow_scale - * series_by_field["port_1_mass_flow"][index] - ) - observed_flow_2 = ( - amesim_mass_flow_scale - * series_by_field["port_2_mass_flow"][index] - ) - upstream_temperature_1 = ( - series_by_field["port_1_temperature"][index] - if observed_flow_1 >= 0.0 - else center_temperature - ) - upstream_temperature_2 = ( - series_by_field["port_2_temperature"][index] - if observed_flow_2 >= 0.0 - else center_temperature - ) - predicted_flow_1 = pipe.mass_flow( - port_1_pressure, - center_pressure, - upstream_temperature_1, - ) - predicted_flow_2 = pipe.mass_flow( - port_2_pressure, - center_pressure, - upstream_temperature_2, - ) - reynolds_1 = pipe.reynolds_number( - observed_flow_1, - upstream_temperature_1, - ) - reynolds_2 = pipe.reynolds_number( - observed_flow_2, - upstream_temperature_2, - ) - friction_1 = pipe.friction_factor(reynolds_1) - friction_2 = pipe.friction_factor(reynolds_2) - replay_reynolds = 0.5 * (reynolds_1 + reynolds_2) - replay_friction = 0.5 * (friction_1 + friction_2) - rows.append( - { - "time": float(time_s), - "centerPressure": center_pressure, - "centerTemperature": center_temperature, - "port1Pressure": port_1_pressure, - "port2Pressure": port_2_pressure, - "observedPort1MassFlow": observed_flow_1, - "observedPort2MassFlow": observed_flow_2, - "predictedPort1MassFlow": predicted_flow_1, - "predictedPort2MassFlow": predicted_flow_2, - "port1MassFlowError": predicted_flow_1 - observed_flow_1, - "port2MassFlowError": predicted_flow_2 - observed_flow_2, - "amesimReynolds": series_by_field["reynolds"][index], - "replayReynolds": replay_reynolds, - "reynoldsError": ( - replay_reynolds - series_by_field["reynolds"][index] - ), - "amesimFrictionFactor": series_by_field["friction_factor"][index], - "replayFrictionFactor": replay_friction, - "frictionFactorError": ( - replay_friction - - series_by_field["friction_factor"][index] - ), - } - ) - - metric_keys = ( - "port1MassFlowError", - "port2MassFlowError", - "reynoldsError", - "frictionFactorError", - ) - return { - "mode": "amesim-state-replay-no-integration", - "component": pipe.name, - "pointCount": len(rows), - "massFlowScale": amesim_mass_flow_scale, - "paths": { - field: getattr(paths, field) - for field in paths.__dataclass_fields__ - }, - "parameters": dict(pipe.parameter_values), - "summary": { - key: _metric_summary(rows, key) - for key in metric_keys - }, - "rows": rows, - } - - -def _compile_project_pipe( - project_path: Path, - component_name: str, -) -> AmesimPnl0002: - from app.main import ( - ReactFlowProjectPayload, - _compile_xml_document_or_422, - _validated_xml_document_or_422, - build_reactflow_system_xml, - validate_system_xml_document, - ) - - payload = ReactFlowProjectPayload.model_validate_json( - project_path.read_text(encoding="utf-8") - ) - xml_bytes = build_reactflow_system_xml(payload) - document = _validated_xml_document_or_422( - validate_system_xml_document(xml_bytes) - ) - _project, network = _compile_xml_document_or_422(document) - try: - component = network.components[component_name] - except KeyError as exc: - raise ValueError(f"Project component does not exist: {component_name}") from exc - if not isinstance(component, AmesimPnl0002): - raise ValueError(f"Project component is not PNL0002: {component_name}") - return component - - -def _paths_from_arguments(arguments: argparse.Namespace) -> Pnl0002ReplayPaths: - values: Mapping[str, str] = { - field: getattr(arguments, field) - for field in PNL83_REPLAY_PATHS.__dataclass_fields__ - } - return Pnl0002ReplayPaths(**values) - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Replay saved AMESim p/T/m_flow through a PNL0002 model without integration." - ) - parser.add_argument("project", type=Path) - parser.add_argument("amesim_archive", type=Path) - parser.add_argument("output", type=Path) - parser.add_argument("--component", default="pneumatic_83") - parser.add_argument("--mass-flow-scale", type=float, default=-1.0e-3) - for field in PNL83_REPLAY_PATHS.__dataclass_fields__: - parser.add_argument( - "--" + field.replace("_", "-"), - dest=field, - default=getattr(PNL83_REPLAY_PATHS, field), - ) - arguments = parser.parse_args() - - pipe = _compile_project_pipe(arguments.project, arguments.component) - results = load_test_mql_amesim_results(arguments.amesim_archive) - report = replay_pnl0002_amesim_states( - pipe, - results, - _paths_from_arguments(arguments), - amesim_mass_flow_scale=arguments.mass_flow_scale, - ) - arguments.output.parent.mkdir(parents=True, exist_ok=True) - arguments.output.write_text( - json.dumps(report, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - print(json.dumps({key: report[key] for key in ( - "mode", - "component", - "pointCount", - "parameters", - "summary", - )}, ensure_ascii=False, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/app/simulation/reporting/test_mql_chamber_observations.py b/app/simulation/reporting/test_mql_chamber_observations.py deleted file mode 100644 index 49d9361..0000000 --- a/app/simulation/reporting/test_mql_chamber_observations.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_variables import ( - TestMqlVariableBinding, - TestMqlVariableCatalog, - build_test_mql_variable_catalog, -) -from app.simulation.examples.test_mql.pneumatic import ( - TestMqlPneumaticAssembly, - build_test_mql_pneumatic_assembly, -) - - -@dataclass(frozen=True) -class TestMqlChamberObservation: - time: float - pressure_pa: float - temperature_k: float - gas_mass_g: float - volume_cm3: float | None - - -@dataclass(frozen=True) -class TestMqlChamberBinding: - alias: str - submodel: str - pressure_path: str - temperature_path: str - gas_mass_path: str - pressure_duplicate_paths: tuple[str, ...] - temperature_duplicate_paths: tuple[str, ...] - volume_path: str | None - - @property - def is_variable(self) -> bool: - return self.volume_path is not None - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlChamberObservation: - return TestMqlChamberObservation( - time=results.times[index], - pressure_pa=results.series(self.pressure_path)[index], - temperature_k=results.series(self.temperature_path)[index], - gas_mass_g=results.series(self.gas_mass_path)[index], - volume_cm3=( - results.series(self.volume_path)[index] - if self.volume_path is not None - else None - ), - ) - - -@dataclass(frozen=True) -class TestMqlChamberObservationCatalog: - bindings: tuple[TestMqlChamberBinding, ...] - - @property - def fixed_count(self) -> int: - return sum(1 for binding in self.bindings if binding.submodel == "PNCH023") - - @property - def variable_count(self) -> int: - return sum(1 for binding in self.bindings if binding.submodel == "PNCH012") - - def by_alias(self, alias: str) -> TestMqlChamberBinding: - for binding in self.bindings: - if binding.alias == alias: - return binding - raise KeyError(alias) - - -def build_test_mql_chamber_observation_catalog( - results: AmesimResults, - *, - variable_catalog: TestMqlVariableCatalog | None = None, - assembly: TestMqlPneumaticAssembly | None = None, -) -> TestMqlChamberObservationCatalog: - variable_catalog = variable_catalog or build_test_mql_variable_catalog(results) - assembly = assembly or build_test_mql_pneumatic_assembly() - chamber_aliases = { - **{alias: "PNCH023" for alias in assembly.fixed_chambers}, - **{alias: "PNCH012" for alias in assembly.variable_chambers}, - } - bindings = [] - for alias, submodel in chamber_aliases.items(): - variables = tuple( - variable - for variable in variable_catalog.variables - if variable.owner_alias == alias - ) - pressure = _primary_observable(variables, "press", expected_units="Pa") - temperature = _primary_observable(variables, "temp", expected_units="K") - gas_mass = _required_path( - variables, - "mgas1" if submodel == "PNCH012" else "mgas", - expected_units="g", - ) - volume = _optional_path(variables, "vol", expected_units="cm**3") - bindings.append( - TestMqlChamberBinding( - alias=alias, - submodel=submodel, - pressure_path=pressure.data_path, - temperature_path=temperature.data_path, - gas_mass_path=gas_mass, - pressure_duplicate_paths=_duplicate_paths(variables, "press", expected_units="Pa"), - temperature_duplicate_paths=_duplicate_paths(variables, "temp", expected_units="K"), - volume_path=volume, - ) - ) - return TestMqlChamberObservationCatalog( - bindings=tuple(sorted(bindings, key=lambda binding: binding.alias)) - ) - - -def _primary_observable( - variables: tuple[TestMqlVariableBinding, ...], - signal_prefix: str, - *, - expected_units: str, -) -> TestMqlVariableBinding: - matches = tuple( - variable - for variable in variables - if variable.signal_name == signal_prefix - and "duplicate" not in variable.label - ) - variable = _single(matches, f"primary {signal_prefix}") - _assert_units(variable, expected_units) - return variable - - -def _duplicate_paths( - variables: tuple[TestMqlVariableBinding, ...], - signal_prefix: str, - *, - expected_units: str, -) -> tuple[str, ...]: - matches = tuple( - variable - for variable in variables - if variable.signal_name.startswith(signal_prefix) - and variable.signal_name != signal_prefix - and "duplicate" in variable.label - ) - for variable in matches: - _assert_units(variable, expected_units) - return tuple(variable.data_path for variable in matches) - - -def _required_path( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, - *, - expected_units: str, -) -> str: - variable = _single( - tuple(variable for variable in variables if variable.signal_name == signal_name), - signal_name, - ) - _assert_units(variable, expected_units) - return variable.data_path - - -def _optional_path( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, - *, - expected_units: str, -) -> str | None: - matches = tuple(variable for variable in variables if variable.signal_name == signal_name) - if not matches: - return None - variable = _single(matches, signal_name) - _assert_units(variable, expected_units) - return variable.data_path - - -def _single( - matches: tuple[TestMqlVariableBinding, ...], - description: str, -) -> TestMqlVariableBinding: - if len(matches) != 1: - raise ValueError(f"Expected one {description} variable, found {len(matches)}.") - return matches[0] - - -def _assert_units(variable: TestMqlVariableBinding, expected_units: str) -> None: - if variable.units != expected_units: - raise ValueError( - f"Unexpected units for {variable.data_path}: " - f"{variable.units!r}, expected {expected_units!r}." - ) diff --git a/app/simulation/reporting/test_mql_comparison.py b/app/simulation/reporting/test_mql_comparison.py deleted file mode 100644 index c65173f..0000000 --- a/app/simulation/reporting/test_mql_comparison.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -from bisect import bisect_left -import csv -from dataclasses import dataclass -from pathlib import Path - -from app.simulation.reporting.amesim_results import AmesimResults - - -DEFAULT_TEST_MQL_ALIGNMENT_PATHS = ( - "temp3@pn_c1_8", - "press3@pn_c1_8", - "vvol1@pn_brp2_8", - "vol1@pn_brp2_8", -) - - -@dataclass(frozen=True) -class TestMqlComparisonMetric: - data_path: str - sample_count: int - max_abs_error: float - mean_abs_error: float - max_rel_error: float - undefined_rel_error_count: int - near_zero_baseline_count: int - final_abs_error: float - - -@dataclass(frozen=True) -class TestMqlComparisonResult: - metrics: tuple[TestMqlComparisonMetric, ...] - - def metric(self, data_path: str) -> TestMqlComparisonMetric: - for metric in self.metrics: - if metric.data_path == data_path: - return metric - raise KeyError(data_path) - - @property - def max_abs_error(self) -> float: - return max((metric.max_abs_error for metric in self.metrics), default=0.0) - - @property - def max_rel_error(self) -> float: - return max((metric.max_rel_error for metric in self.metrics), default=0.0) - - @property - def undefined_rel_error_count(self) -> int: - return sum(metric.undefined_rel_error_count for metric in self.metrics) - - -class TestMqlComparisonError(ValueError): - """Raised when Python and AMESim series cannot be aligned.""" - - -def compare_test_mql_series( - *, - python_times: tuple[float, ...] | list[float], - python_series_by_data_path: dict[str, tuple[float, ...] | list[float]], - amesim_results: AmesimResults, - data_paths: tuple[str, ...] | list[str] | None = None, - relative_floor: float = 1.0e-12, -) -> TestMqlComparisonResult: - """Compare current values directly with AMESim simulation values. - - ``relative_floor`` only identifies near-zero baselines for reporting. It is - never substituted into the relative-error denominator. An exact zero - AMESim baseline has undefined relative error and is counted separately; - absolute error remains available for judgement. - """ - - if relative_floor < 0.0: - raise TestMqlComparisonError("relative_floor cannot be negative.") - _validate_time_axis(python_times) - selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths) - metrics = [] - for data_path in selected_paths: - python_values = tuple(float(value) for value in python_series_by_data_path[data_path]) - if len(python_values) != len(python_times): - raise TestMqlComparisonError( - f"Python series length mismatch for {data_path!r}: " - f"{len(python_values)} values for {len(python_times)} time samples." - ) - amesim_values = amesim_results.series(data_path) - abs_errors = [] - rel_errors = [] - undefined_rel_error_count = 0 - near_zero_baseline_count = 0 - for time_value, python_value in zip(python_times, python_values): - amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value) - abs_error = abs(python_value - amesim_value) - abs_errors.append(abs_error) - if abs(amesim_value) <= relative_floor: - near_zero_baseline_count += 1 - if amesim_value == 0.0: - undefined_rel_error_count += 1 - else: - rel_errors.append(abs_error / abs(amesim_value)) - final_amesim_value = interpolate_series_value( - amesim_results.times, - amesim_values, - float(python_times[-1]), - ) - metrics.append( - TestMqlComparisonMetric( - data_path=data_path, - sample_count=len(python_times), - max_abs_error=max(abs_errors, default=0.0), - mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1), - max_rel_error=max(rel_errors, default=0.0), - undefined_rel_error_count=undefined_rel_error_count, - near_zero_baseline_count=near_zero_baseline_count, - final_abs_error=abs(python_values[-1] - final_amesim_value), - ) - ) - return TestMqlComparisonResult(metrics=tuple(metrics)) - - -def write_test_mql_amesim_baseline_csv( - output_dir: Path, - amesim_results: AmesimResults, - data_paths: tuple[str, ...] | list[str] = DEFAULT_TEST_MQL_ALIGNMENT_PATHS, -) -> Path: - output_dir.mkdir(parents=True, exist_ok=True) - csv_path = output_dir / "test_mql_amesim_baseline.csv" - _validate_amesim_data_paths(amesim_results, data_paths) - with csv_path.open("w", newline="", encoding="utf-8") as handle: - writer = csv.writer(handle) - writer.writerow(["time_s", *data_paths]) - for index, time_value in enumerate(amesim_results.times): - writer.writerow( - [time_value, *(amesim_results.series(data_path)[index] for data_path in data_paths)] - ) - return csv_path - - -def write_test_mql_comparison_csv( - *, - output_dir: Path, - python_times: tuple[float, ...] | list[float], - python_series_by_data_path: dict[str, tuple[float, ...] | list[float]], - amesim_results: AmesimResults, - data_paths: tuple[str, ...] | list[str] | None = None, -) -> tuple[Path, Path, TestMqlComparisonResult]: - output_dir.mkdir(parents=True, exist_ok=True) - selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths) - comparison = compare_test_mql_series( - python_times=python_times, - python_series_by_data_path=python_series_by_data_path, - amesim_results=amesim_results, - data_paths=selected_paths, - ) - csv_path = output_dir / "test_mql_amesim_comparison.csv" - summary_path = output_dir / "test_mql_amesim_comparison_summary.txt" - - with csv_path.open("w", newline="", encoding="utf-8") as handle: - writer = csv.writer(handle) - header = ["time_s"] - for data_path in selected_paths: - header.extend( - [ - f"python.{data_path}", - f"amesim.{data_path}", - f"abs_error.{data_path}", - f"rel_error.{data_path}", - ] - ) - writer.writerow(header) - for index, time_value in enumerate(python_times): - row = [time_value] - for data_path in selected_paths: - python_value = float(python_series_by_data_path[data_path][index]) - amesim_value = interpolate_series_value( - amesim_results.times, - amesim_results.series(data_path), - float(time_value), - ) - abs_error = abs(python_value - amesim_value) - rel_error = ( - None - if amesim_value == 0.0 - else abs_error / abs(amesim_value) - ) - row.extend( - [ - python_value, - amesim_value, - abs_error, - "" if rel_error is None else rel_error, - ] - ) - writer.writerow(row) - - summary_lines = [ - ( - f"{metric.data_path}: samples={metric.sample_count}, " - f"max_abs_error={metric.max_abs_error:.12g}, " - f"mean_abs_error={metric.mean_abs_error:.12g}, " - f"max_rel_error={metric.max_rel_error:.12%}, " - f"undefined_rel_error_count={metric.undefined_rel_error_count}, " - f"final_abs_error={metric.final_abs_error:.12g}" - ) - for metric in comparison.metrics - ] - summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8") - return csv_path, summary_path, comparison - - -def interpolate_series_value( - time_values: tuple[float, ...] | list[float], - values: tuple[float, ...] | list[float], - target_time: float, -) -> float: - if len(time_values) != len(values): - raise TestMqlComparisonError("time and value series lengths differ.") - if not time_values: - raise TestMqlComparisonError("cannot interpolate an empty series.") - if target_time <= time_values[0]: - return float(values[0]) - if target_time >= time_values[-1]: - return float(values[-1]) - - right_index = bisect_left(time_values, target_time) - if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1.0e-12: - return float(values[right_index]) - - left_index = right_index - 1 - left_time = float(time_values[left_index]) - right_time = float(time_values[right_index]) - fraction = (target_time - left_time) / (right_time - left_time) - return float(values[left_index]) + fraction * (float(values[right_index]) - float(values[left_index])) - - -def _select_data_paths( - python_series_by_data_path: dict[str, tuple[float, ...] | list[float]], - amesim_results: AmesimResults, - data_paths: tuple[str, ...] | list[str] | None, -) -> tuple[str, ...]: - if data_paths is None: - data_paths = tuple( - data_path - for data_path in python_series_by_data_path - if data_path in amesim_results.series_by_data_path - ) - selected_paths = tuple(data_paths) - if not selected_paths: - raise TestMqlComparisonError("no common Data_Path values are available for comparison.") - missing_python = [data_path for data_path in selected_paths if data_path not in python_series_by_data_path] - if missing_python: - raise TestMqlComparisonError(f"Python series missing Data_Path values: {missing_python}") - _validate_amesim_data_paths(amesim_results, selected_paths) - return selected_paths - - -def _validate_amesim_data_paths( - amesim_results: AmesimResults, - data_paths: tuple[str, ...] | list[str], -) -> None: - missing_amesim = [data_path for data_path in data_paths if data_path not in amesim_results.series_by_data_path] - if missing_amesim: - raise TestMqlComparisonError(f"AMESim results missing Data_Path values: {missing_amesim}") - - -def _validate_time_axis(time_values: tuple[float, ...] | list[float]) -> None: - if not time_values: - raise TestMqlComparisonError("Python time axis is empty.") - previous = float(time_values[0]) - for value in time_values[1:]: - value = float(value) - if value < previous: - raise TestMqlComparisonError("Python time axis must be monotonically increasing.") - previous = value diff --git a/app/simulation/reporting/test_mql_line_observations.py b/app/simulation/reporting/test_mql_line_observations.py deleted file mode 100644 index 86904b3..0000000 --- a/app/simulation/reporting/test_mql_line_observations.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_variables import ( - TestMqlVariableBinding, - TestMqlVariableCatalog, - build_test_mql_variable_catalog, -) -from app.simulation.examples.test_mql.lines import ( - TestMqlLineAssembly, - build_test_mql_line_assembly, -) - - -G_PER_S_TO_KG_PER_S = 1.0e-3 - - -@dataclass(frozen=True) -class TestMqlLineObservation: - time: float - mass_flows_kg_s: dict[str, float] - enthalpy_flows_w: dict[str, float] - pressures_pa: dict[str, float] - temperatures_k: dict[str, float] - gas_mass_g: float | None - reynolds_number: float - mass_flow_parameter: float - gas_velocity_m_s: float - friction_factor: float - - -@dataclass(frozen=True) -class TestMqlLineObservationBinding: - alias: str - submodel: str - pattern: str - mass_flow_paths: tuple[str, ...] - enthalpy_flow_paths: tuple[str, ...] - pressure_paths: tuple[str, ...] - temperature_paths: tuple[str, ...] - gas_mass_path: str | None - reynolds_path: str - mass_flow_parameter_path: str - gas_velocity_path: str - friction_factor_path: str - - def mass_flow_kg_s_series( - self, - results: AmesimResults, - data_path: str | None = None, - ) -> tuple[float, ...]: - path = data_path or self.mass_flow_paths[0] - if path not in self.mass_flow_paths: - raise KeyError(path) - return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(path)) - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlLineObservation: - return TestMqlLineObservation( - time=results.times[index], - mass_flows_kg_s={ - path: results.series(path)[index] * G_PER_S_TO_KG_PER_S - for path in self.mass_flow_paths - }, - enthalpy_flows_w={ - path: results.series(path)[index] - for path in self.enthalpy_flow_paths - }, - pressures_pa={ - path: results.series(path)[index] - for path in self.pressure_paths - }, - temperatures_k={ - path: results.series(path)[index] - for path in self.temperature_paths - }, - gas_mass_g=( - results.series(self.gas_mass_path)[index] - if self.gas_mass_path is not None - else None - ), - reynolds_number=results.series(self.reynolds_path)[index], - mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index], - gas_velocity_m_s=results.series(self.gas_velocity_path)[index], - friction_factor=results.series(self.friction_factor_path)[index], - ) - - -@dataclass(frozen=True) -class TestMqlLineObservationCatalog: - bindings: tuple[TestMqlLineObservationBinding, ...] - - @property - def line_count(self) -> int: - return len(self.bindings) - - def by_alias(self, alias: str) -> TestMqlLineObservationBinding: - for binding in self.bindings: - if binding.alias == alias: - return binding - raise KeyError(alias) - - def by_submodel(self, submodel: str) -> tuple[TestMqlLineObservationBinding, ...]: - return tuple(binding for binding in self.bindings if binding.submodel == submodel) - - -def build_test_mql_line_observation_catalog( - results: AmesimResults, - *, - variable_catalog: TestMqlVariableCatalog | None = None, - line_assembly: TestMqlLineAssembly | None = None, -) -> TestMqlLineObservationCatalog: - variable_catalog = variable_catalog or build_test_mql_variable_catalog(results) - line_assembly = line_assembly or build_test_mql_line_assembly(results, variable_catalog) - bindings = [] - for line in line_assembly.lines: - variables = tuple( - variable - for variable in variable_catalog.variables - if variable.owner_alias == line.alias - ) - bindings.append( - TestMqlLineObservationBinding( - alias=line.alias, - submodel=line.submodel, - pattern=line.pattern, - mass_flow_paths=_paths_with_prefix(variables, "dm", expected_units="g/s"), - enthalpy_flow_paths=_paths_with_prefix(variables, "dh", expected_units="J/s"), - pressure_paths=_paths_with_prefix(variables, "p", expected_units="Pa"), - temperature_paths=_paths_with_prefix(variables, "t", expected_units="K"), - gas_mass_path=_optional_path(variables, "mgas", expected_units="g"), - reynolds_path=_required_path(variables, "re", expected_units=None), - mass_flow_parameter_path=_required_path( - variables, - "cm", - expected_units="(kg*K/J)**(1/2)", - ), - gas_velocity_path=_required_path(variables, "v", expected_units="m/s"), - friction_factor_path=_required_path(variables, "ff", expected_units=None), - ) - ) - return TestMqlLineObservationCatalog(bindings=tuple(bindings)) - - -def _paths_with_prefix( - variables: tuple[TestMqlVariableBinding, ...], - prefix: str, - *, - expected_units: str | None, -) -> tuple[str, ...]: - matches = tuple( - variable - for variable in variables - if variable.signal_name.startswith(prefix) - ) - for variable in matches: - _assert_units(variable, expected_units) - return tuple(variable.data_path for variable in matches) - - -def _required_path( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, - *, - expected_units: str | None, -) -> str: - variable = _single_signal(variables, signal_name) - _assert_units(variable, expected_units) - return variable.data_path - - -def _optional_path( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, - *, - expected_units: str | None, -) -> str | None: - matches = tuple(variable for variable in variables if variable.signal_name == signal_name) - if not matches: - return None - variable = _single(matches, signal_name) - _assert_units(variable, expected_units) - return variable.data_path - - -def _single_signal( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, -) -> TestMqlVariableBinding: - return _single( - tuple(variable for variable in variables if variable.signal_name == signal_name), - signal_name, - ) - - -def _single( - matches: tuple[TestMqlVariableBinding, ...], - description: str, -) -> TestMqlVariableBinding: - if len(matches) != 1: - raise ValueError(f"Expected one {description} variable, found {len(matches)}.") - return matches[0] - - -def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None: - if variable.units != expected_units: - raise ValueError( - f"Unexpected units for {variable.data_path}: " - f"{variable.units!r}, expected {expected_units!r}." - ) diff --git a/app/simulation/reporting/test_mql_mechanical_observations.py b/app/simulation/reporting/test_mql_mechanical_observations.py deleted file mode 100644 index f2c8776..0000000 --- a/app/simulation/reporting/test_mql_mechanical_observations.py +++ /dev/null @@ -1,395 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_variables import ( - TestMqlVariableBinding, - TestMqlVariableCatalog, - build_test_mql_variable_catalog, -) -from app.simulation.examples.test_mql.mechanical import ( - TestMqlMechanicalAssembly, - build_test_mql_mechanical_assembly, -) - - -@dataclass(frozen=True) -class TestMqlPistonObservation: - time: float - chamber_volume_cm3: float - chamber_volume_rate_l_min: float - chamber_length_mm: float - force_port_2_n: float - force_port_3_n: float - displacement_port_2_m: float - velocity_port_2_m_s: float - displacement_port_3_m: float - velocity_port_3_m_s: float - - -@dataclass(frozen=True) -class TestMqlMassEndstopObservation: - time: float - displacement_m: float - velocity_m_s: float - acceleration_m_s2: float - lower_contact_force_n: float - upper_contact_force_n: float - viscous_friction_force_n: float - dry_friction_force_n: float - stick_flag: float - - -@dataclass(frozen=True) -class TestMqlElasticEndstopObservation: - time: float - force_n: float - duplicate_force_n: float - gap_mm: float - stiffness_n_m: float - - -@dataclass(frozen=True) -class TestMqlForceSourceObservation: - time: float - force_n: float - - -@dataclass(frozen=True) -class TestMqlForceConnectorObservation: - time: float - force_n: float - - -@dataclass(frozen=True) -class TestMqlMechanicalNodeObservation: - time: float - velocities_m_s: dict[int, float] - displacements_m: dict[int, float] - total_force_n: float - - -@dataclass(frozen=True) -class TestMqlPistonObservationBinding: - alias: str - volume_path: str - volume_rate_path: str - length_path: str - force_port_2_path: str - force_port_3_path: str - displacement_port_2_path: str - velocity_port_2_path: str - displacement_port_3_path: str - velocity_port_3_path: str - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlPistonObservation: - return TestMqlPistonObservation( - time=results.times[index], - chamber_volume_cm3=results.series(self.volume_path)[index], - chamber_volume_rate_l_min=results.series(self.volume_rate_path)[index], - chamber_length_mm=results.series(self.length_path)[index], - force_port_2_n=results.series(self.force_port_2_path)[index], - force_port_3_n=results.series(self.force_port_3_path)[index], - displacement_port_2_m=results.series(self.displacement_port_2_path)[index], - velocity_port_2_m_s=results.series(self.velocity_port_2_path)[index], - displacement_port_3_m=results.series(self.displacement_port_3_path)[index], - velocity_port_3_m_s=results.series(self.velocity_port_3_path)[index], - ) - - -@dataclass(frozen=True) -class TestMqlMassEndstopObservationBinding: - alias: str - displacement_path: str - velocity_path: str - acceleration_path: str - displacement_duplicate_path: str - velocity_duplicate_path: str - acceleration_duplicate_path: str - lower_contact_force_path: str - upper_contact_force_path: str - viscous_friction_force_path: str - dry_friction_force_path: str - stick_flag_path: str - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlMassEndstopObservation: - return TestMqlMassEndstopObservation( - time=results.times[index], - displacement_m=results.series(self.displacement_path)[index], - velocity_m_s=results.series(self.velocity_path)[index], - acceleration_m_s2=results.series(self.acceleration_path)[index], - lower_contact_force_n=results.series(self.lower_contact_force_path)[index], - upper_contact_force_n=results.series(self.upper_contact_force_path)[index], - viscous_friction_force_n=results.series(self.viscous_friction_force_path)[index], - dry_friction_force_n=results.series(self.dry_friction_force_path)[index], - stick_flag=results.series(self.stick_flag_path)[index], - ) - - -@dataclass(frozen=True) -class TestMqlElasticEndstopObservationBinding: - alias: str - force_path: str - duplicate_force_path: str - gap_path: str - stiffness_path: str - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlElasticEndstopObservation: - return TestMqlElasticEndstopObservation( - time=results.times[index], - force_n=results.series(self.force_path)[index], - duplicate_force_n=results.series(self.duplicate_force_path)[index], - gap_mm=results.series(self.gap_path)[index], - stiffness_n_m=results.series(self.stiffness_path)[index], - ) - - -@dataclass(frozen=True) -class TestMqlForceSourceObservationBinding: - alias: str - force_path: str - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceSourceObservation: - return TestMqlForceSourceObservation( - time=results.times[index], - force_n=results.series(self.force_path)[index], - ) - - -@dataclass(frozen=True) -class TestMqlForceConnectorObservationBinding: - alias: str - force_path: str - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceConnectorObservation: - return TestMqlForceConnectorObservation( - time=results.times[index], - force_n=results.series(self.force_path)[index], - ) - - -@dataclass(frozen=True) -class TestMqlMechanicalNodeObservationBinding: - alias: str - velocity_paths_by_port: dict[int, str] - displacement_paths_by_port: dict[int, str] - total_force_path: str - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlMechanicalNodeObservation: - return TestMqlMechanicalNodeObservation( - time=results.times[index], - velocities_m_s={ - port: results.series(path)[index] - for port, path in self.velocity_paths_by_port.items() - }, - displacements_m={ - port: results.series(path)[index] - for port, path in self.displacement_paths_by_port.items() - }, - total_force_n=results.series(self.total_force_path)[index], - ) - - -@dataclass(frozen=True) -class TestMqlMechanicalObservationCatalog: - pistons: dict[str, TestMqlPistonObservationBinding] - masses: dict[str, TestMqlMassEndstopObservationBinding] - elastic_endstops: dict[str, TestMqlElasticEndstopObservationBinding] - zero_force_sources: dict[str, TestMqlForceSourceObservationBinding] - force_connectors: dict[str, TestMqlForceConnectorObservationBinding] - mechanical_nodes: dict[str, TestMqlMechanicalNodeObservationBinding] - - @property - def binding_count(self) -> int: - return ( - len(self.pistons) - + len(self.masses) - + len(self.elastic_endstops) - + len(self.zero_force_sources) - + len(self.force_connectors) - + len(self.mechanical_nodes) - ) - - -def build_test_mql_mechanical_observation_catalog( - results: AmesimResults, - *, - variable_catalog: TestMqlVariableCatalog | None = None, - mechanical_assembly: TestMqlMechanicalAssembly | None = None, -) -> TestMqlMechanicalObservationCatalog: - variable_catalog = variable_catalog or build_test_mql_variable_catalog(results) - mechanical_assembly = mechanical_assembly or build_test_mql_mechanical_assembly( - amesim_results=results, - variable_catalog=variable_catalog, - ) - return TestMqlMechanicalObservationCatalog( - pistons={ - alias: _build_piston_binding(alias, variable_catalog) - for alias in mechanical_assembly.pistons - }, - masses={ - alias: _build_mass_binding(alias, variable_catalog) - for alias in mechanical_assembly.masses - }, - elastic_endstops={ - alias: _build_elastic_endstop_binding(alias, variable_catalog) - for alias in mechanical_assembly.elastic_endstops - }, - zero_force_sources={ - alias: _build_zero_force_source_binding(alias, variable_catalog) - for alias in mechanical_assembly.zero_force_sources - }, - force_connectors={ - alias: _build_force_connector_binding(alias, variable_catalog) - for alias in mechanical_assembly.force_connectors - }, - mechanical_nodes={ - alias: _build_mechanical_node_binding(alias, variable_catalog) - for alias in mechanical_assembly.mechanical_nodes - }, - ) - - -def _build_piston_binding( - alias: str, - variable_catalog: TestMqlVariableCatalog, -) -> TestMqlPistonObservationBinding: - variables = _owner_variables(variable_catalog, alias) - return TestMqlPistonObservationBinding( - alias=alias, - volume_path=_required_path(variables, "vol1", expected_units="cm**3"), - volume_rate_path=_required_path(variables, "vvol1", expected_units="L/min"), - length_path=_required_path(variables, "length", expected_units="mm"), - force_port_2_path=_required_path(variables, "f2", expected_units="N"), - force_port_3_path=_required_path(variables, "f3", expected_units="N"), - displacement_port_2_path=_required_path(variables, "x5", expected_units="m"), - velocity_port_2_path=_required_path(variables, "v5", expected_units="m/s"), - displacement_port_3_path=_required_path(variables, "x4", expected_units="m"), - velocity_port_3_path=_required_path(variables, "v4", expected_units="m/s"), - ) - - -def _build_mass_binding( - alias: str, - variable_catalog: TestMqlVariableCatalog, -) -> TestMqlMassEndstopObservationBinding: - variables = _owner_variables(variable_catalog, alias) - return TestMqlMassEndstopObservationBinding( - alias=alias, - displacement_path=_required_path(variables, "x1", expected_units="m"), - velocity_path=_required_path(variables, "v1", expected_units="m/s"), - acceleration_path=_required_path(variables, "acc1", expected_units="m/s/s"), - displacement_duplicate_path=_required_path(variables, "x1dup", expected_units="m"), - velocity_duplicate_path=_required_path(variables, "v1dup", expected_units="m/s"), - acceleration_duplicate_path=_required_path(variables, "acc1dup", expected_units="m/s/s"), - lower_contact_force_path=_required_path(variables, "Fmin", expected_units="N"), - upper_contact_force_path=_required_path(variables, "Fmax", expected_units="N"), - viscous_friction_force_path=_required_path(variables, "Fvisc", expected_units="N"), - dry_friction_force_path=_required_path(variables, "Ffric", expected_units="N"), - stick_flag_path=_required_path(variables, "stick", expected_units=None), - ) - - -def _build_elastic_endstop_binding( - alias: str, - variable_catalog: TestMqlVariableCatalog, -) -> TestMqlElasticEndstopObservationBinding: - variables = _owner_variables(variable_catalog, alias) - return TestMqlElasticEndstopObservationBinding( - alias=alias, - force_path=_required_path(variables, "f1", expected_units="N"), - duplicate_force_path=_required_path(variables, "f2", expected_units="N"), - gap_path=_required_path(variables, "gap", expected_units="mm"), - stiffness_path=_required_path(variables, "kval", expected_units="N/m"), - ) - - -def _build_zero_force_source_binding( - alias: str, - variable_catalog: TestMqlVariableCatalog, -) -> TestMqlForceSourceObservationBinding: - variables = _owner_variables(variable_catalog, alias) - return TestMqlForceSourceObservationBinding( - alias=alias, - force_path=_required_path(variables, "fzero", expected_units="N"), - ) - - -def _build_force_connector_binding( - alias: str, - variable_catalog: TestMqlVariableCatalog, -) -> TestMqlForceConnectorObservationBinding: - variables = _owner_variables(variable_catalog, alias) - return TestMqlForceConnectorObservationBinding( - alias=alias, - force_path=_required_path(variables, "force", expected_units="N"), - ) - - -def _build_mechanical_node_binding( - alias: str, - variable_catalog: TestMqlVariableCatalog, -) -> TestMqlMechanicalNodeObservationBinding: - variables = _owner_variables(variable_catalog, alias) - velocity_paths_by_port = {} - displacement_paths_by_port = {} - for port in range(1, 9): - velocity_paths_by_port[port] = _required_path( - variables, - f"p{port}__vt", - expected_units="m/s", - ) - displacement_paths_by_port[port] = _required_path( - variables, - f"p{port}__xt", - expected_units="m", - ) - return TestMqlMechanicalNodeObservationBinding( - alias=alias, - velocity_paths_by_port=velocity_paths_by_port, - displacement_paths_by_port=displacement_paths_by_port, - total_force_path=_required_path(variables, "tforce", expected_units="N"), - ) - - -def _owner_variables( - variable_catalog: TestMqlVariableCatalog, - alias: str, -) -> tuple[TestMqlVariableBinding, ...]: - return tuple( - variable - for variable in variable_catalog.variables - if variable.owner_alias == alias - ) - - -def _required_path( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, - *, - expected_units: str | None, -) -> str: - variable = _single( - tuple(variable for variable in variables if variable.signal_name == signal_name), - signal_name, - ) - _assert_units(variable, expected_units) - return variable.data_path - - -def _single( - matches: tuple[TestMqlVariableBinding, ...], - description: str, -) -> TestMqlVariableBinding: - if len(matches) != 1: - raise ValueError(f"Expected one {description} variable, found {len(matches)}.") - return matches[0] - - -def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None: - if variable.units != expected_units: - raise ValueError( - f"Unexpected units for {variable.data_path}: " - f"{variable.units!r}, expected {expected_units!r}." - ) diff --git a/app/simulation/reporting/test_mql_observations.py b/app/simulation/reporting/test_mql_observations.py deleted file mode 100644 index 1680a9f..0000000 --- a/app/simulation/reporting/test_mql_observations.py +++ /dev/null @@ -1,210 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_chamber_observations import ( - TestMqlChamberObservationCatalog, - build_test_mql_chamber_observation_catalog, -) -from app.simulation.reporting.test_mql_line_observations import ( - TestMqlLineObservationCatalog, - build_test_mql_line_observation_catalog, -) -from app.simulation.reporting.test_mql_mechanical_observations import ( - TestMqlMechanicalObservationCatalog, - build_test_mql_mechanical_observation_catalog, -) -from app.simulation.reporting.test_mql_orifice_observations import ( - TestMqlOrificeObservationCatalog, - build_test_mql_orifice_observation_catalog, -) -from app.simulation.reporting.test_mql_variables import ( - TestMqlVariableCatalog, - build_test_mql_variable_catalog, -) - - -@dataclass(frozen=True) -class TestMqlObservationCatalog: - variable_catalog: TestMqlVariableCatalog - chambers: TestMqlChamberObservationCatalog - orifices: TestMqlOrificeObservationCatalog - lines: TestMqlLineObservationCatalog - mechanical: TestMqlMechanicalObservationCatalog - - @property - def binding_count(self) -> int: - return ( - len(self.chambers.bindings) - + len(self.orifices.bindings) - + self.lines.line_count - + self.mechanical.binding_count - ) - - def data_paths_by_domain(self) -> dict[str, tuple[str, ...]]: - return { - "chambers": _sorted_unique(_chamber_data_paths(self.chambers)), - "orifices": _sorted_unique(_orifice_data_paths(self.orifices)), - "lines": _sorted_unique(_line_data_paths(self.lines)), - "mechanical": _sorted_unique(_mechanical_data_paths(self.mechanical)), - } - - def data_paths(self) -> tuple[str, ...]: - paths = [] - for domain_paths in self.data_paths_by_domain().values(): - paths.extend(domain_paths) - return _sorted_unique(paths) - - def baseline_series_by_data_path( - self, - results: AmesimResults, - data_paths: tuple[str, ...] | list[str] | None = None, - ) -> dict[str, tuple[float, ...]]: - selected_paths = tuple(data_paths) if data_paths is not None else self.data_paths() - _validate_observed_paths(self, selected_paths) - return {data_path: results.series(data_path) for data_path in selected_paths} - - -def build_test_mql_observation_catalog(results: AmesimResults) -> TestMqlObservationCatalog: - variable_catalog = build_test_mql_variable_catalog(results) - return TestMqlObservationCatalog( - variable_catalog=variable_catalog, - chambers=build_test_mql_chamber_observation_catalog( - results, - variable_catalog=variable_catalog, - ), - orifices=build_test_mql_orifice_observation_catalog( - results, - variable_catalog=variable_catalog, - ), - lines=build_test_mql_line_observation_catalog( - results, - variable_catalog=variable_catalog, - ), - mechanical=build_test_mql_mechanical_observation_catalog( - results, - variable_catalog=variable_catalog, - ), - ) - - -def _chamber_data_paths(catalog: TestMqlChamberObservationCatalog) -> tuple[str, ...]: - paths = [] - for binding in catalog.bindings: - paths.extend( - [ - binding.pressure_path, - binding.temperature_path, - binding.gas_mass_path, - *binding.pressure_duplicate_paths, - *binding.temperature_duplicate_paths, - ] - ) - if binding.volume_path is not None: - paths.append(binding.volume_path) - return tuple(paths) - - -def _orifice_data_paths(catalog: TestMqlOrificeObservationCatalog) -> tuple[str, ...]: - paths = [] - for binding in catalog.bindings: - paths.extend( - [ - binding.primary_mass_flow_path, - binding.primary_enthalpy_flow_path, - binding.reversed_mass_flow_path, - binding.reversed_enthalpy_flow_path, - binding.mass_flow_parameter_path, - binding.gas_velocity_path, - ] - ) - if binding.opening_path is not None: - paths.append(binding.opening_path) - return tuple(paths) - - -def _line_data_paths(catalog: TestMqlLineObservationCatalog) -> tuple[str, ...]: - paths = [] - for binding in catalog.bindings: - paths.extend(binding.mass_flow_paths) - paths.extend(binding.enthalpy_flow_paths) - paths.extend(binding.pressure_paths) - paths.extend(binding.temperature_paths) - if binding.gas_mass_path is not None: - paths.append(binding.gas_mass_path) - paths.extend( - [ - binding.reynolds_path, - binding.mass_flow_parameter_path, - binding.gas_velocity_path, - binding.friction_factor_path, - ] - ) - return tuple(paths) - - -def _mechanical_data_paths(catalog: TestMqlMechanicalObservationCatalog) -> tuple[str, ...]: - paths = [] - for binding in catalog.pistons.values(): - paths.extend( - [ - binding.volume_path, - binding.volume_rate_path, - binding.length_path, - binding.force_port_2_path, - binding.force_port_3_path, - binding.displacement_port_2_path, - binding.velocity_port_2_path, - binding.displacement_port_3_path, - binding.velocity_port_3_path, - ] - ) - for binding in catalog.masses.values(): - paths.extend( - [ - binding.displacement_path, - binding.velocity_path, - binding.acceleration_path, - binding.displacement_duplicate_path, - binding.velocity_duplicate_path, - binding.acceleration_duplicate_path, - binding.lower_contact_force_path, - binding.upper_contact_force_path, - binding.viscous_friction_force_path, - binding.dry_friction_force_path, - binding.stick_flag_path, - ] - ) - for binding in catalog.elastic_endstops.values(): - paths.extend( - [ - binding.force_path, - binding.duplicate_force_path, - binding.gap_path, - binding.stiffness_path, - ] - ) - for binding in catalog.zero_force_sources.values(): - paths.append(binding.force_path) - for binding in catalog.force_connectors.values(): - paths.append(binding.force_path) - for binding in catalog.mechanical_nodes.values(): - paths.extend(binding.velocity_paths_by_port.values()) - paths.extend(binding.displacement_paths_by_port.values()) - paths.append(binding.total_force_path) - return tuple(paths) - - -def _validate_observed_paths( - catalog: TestMqlObservationCatalog, - data_paths: tuple[str, ...], -) -> None: - observed_paths = set(catalog.data_paths()) - missing = [data_path for data_path in data_paths if data_path not in observed_paths] - if missing: - raise KeyError(f"Data_Path values are not in the test_mql observation catalog: {missing}") - - -def _sorted_unique(data_paths: tuple[str, ...] | list[str]) -> tuple[str, ...]: - return tuple(sorted(set(data_paths))) diff --git a/app/simulation/reporting/test_mql_orifice_observations.py b/app/simulation/reporting/test_mql_orifice_observations.py deleted file mode 100644 index 3279d12..0000000 --- a/app/simulation/reporting/test_mql_orifice_observations.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_variables import ( - TestMqlVariableBinding, - TestMqlVariableCatalog, - build_test_mql_variable_catalog, -) -from app.simulation.examples.test_mql.pneumatic import ( - TestMqlPneumaticAssembly, - build_test_mql_pneumatic_assembly, -) - - -G_PER_S_TO_KG_PER_S = 1.0e-3 - - -@dataclass(frozen=True) -class TestMqlOrificeObservation: - time: float - mass_flow_kg_s: float - enthalpy_flow_w: float - mass_flow_parameter: float - gas_velocity_m_s: float - opening: float - effective_area_m2: float - - -@dataclass(frozen=True) -class TestMqlOrificeBinding: - alias: str - submodel: str - nominal_area_m2: float - flow_coefficient: float - primary_mass_flow_path: str - primary_enthalpy_flow_path: str - reversed_mass_flow_path: str - reversed_enthalpy_flow_path: str - mass_flow_parameter_path: str - gas_velocity_path: str - opening_path: str | None - - @property - def is_variable(self) -> bool: - return self.opening_path is not None - - def opening_series(self, results: AmesimResults) -> tuple[float, ...]: - if self.opening_path is None: - return tuple(1.0 for _ in results.times) - return tuple(results.series(self.opening_path)) - - def mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]: - return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.primary_mass_flow_path)) - - def reversed_mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]: - return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.reversed_mass_flow_path)) - - def effective_area_series(self, results: AmesimResults) -> tuple[float, ...]: - return tuple(self.nominal_area_m2 * max(opening, 0.0) for opening in self.opening_series(results)) - - def observation_at(self, results: AmesimResults, index: int) -> TestMqlOrificeObservation: - opening = self.opening_series(results)[index] - return TestMqlOrificeObservation( - time=results.times[index], - mass_flow_kg_s=results.series(self.primary_mass_flow_path)[index] * G_PER_S_TO_KG_PER_S, - enthalpy_flow_w=results.series(self.primary_enthalpy_flow_path)[index], - mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index], - gas_velocity_m_s=results.series(self.gas_velocity_path)[index], - opening=opening, - effective_area_m2=self.nominal_area_m2 * max(opening, 0.0), - ) - - -@dataclass(frozen=True) -class TestMqlOrificeObservationCatalog: - bindings: tuple[TestMqlOrificeBinding, ...] - - @property - def fixed_count(self) -> int: - return sum(1 for binding in self.bindings if binding.submodel == "PNOR001") - - @property - def variable_count(self) -> int: - return sum(1 for binding in self.bindings if binding.submodel == "PNVO001") - - def by_alias(self, alias: str) -> TestMqlOrificeBinding: - for binding in self.bindings: - if binding.alias == alias: - return binding - raise KeyError(alias) - - -def build_test_mql_orifice_observation_catalog( - results: AmesimResults, - *, - variable_catalog: TestMqlVariableCatalog | None = None, - assembly: TestMqlPneumaticAssembly | None = None, -) -> TestMqlOrificeObservationCatalog: - variable_catalog = variable_catalog or build_test_mql_variable_catalog(results) - assembly = assembly or build_test_mql_pneumatic_assembly() - bindings = [] - for alias, orifice in { - **assembly.fixed_orifices, - **assembly.variable_orifices, - }.items(): - owner_variables = tuple( - variable - for variable in variable_catalog.variables - if variable.owner_alias == alias - ) - primary_mass_flow = _find_primary(owner_variables, signal_prefix="dm") - primary_enthalpy_flow = _find_primary(owner_variables, signal_prefix="dh") - reversed_mass_flow = _find_reversed(owner_variables, signal_prefix="dm") - reversed_enthalpy_flow = _find_reversed(owner_variables, signal_prefix="dh") - mass_flow_parameter = _find_by_signal(owner_variables, "cm") - gas_velocity = _find_by_signal(owner_variables, "gasvel") - opening = _find_optional_by_signal(owner_variables, "xv") - bindings.append( - TestMqlOrificeBinding( - alias=alias, - submodel=primary_mass_flow.submodel, - nominal_area_m2=orifice.area, - flow_coefficient=orifice.flow_coefficient, - primary_mass_flow_path=primary_mass_flow.data_path, - primary_enthalpy_flow_path=primary_enthalpy_flow.data_path, - reversed_mass_flow_path=reversed_mass_flow.data_path, - reversed_enthalpy_flow_path=reversed_enthalpy_flow.data_path, - mass_flow_parameter_path=mass_flow_parameter.data_path, - gas_velocity_path=gas_velocity.data_path, - opening_path=opening.data_path if opening is not None else None, - ) - ) - return TestMqlOrificeObservationCatalog( - bindings=tuple(sorted(bindings, key=lambda binding: binding.alias)) - ) - - -def _find_primary( - variables: tuple[TestMqlVariableBinding, ...], - *, - signal_prefix: str, -) -> TestMqlVariableBinding: - matches = [ - variable - for variable in variables - if variable.signal_name.startswith(signal_prefix) - and "sign reversed duplicate" not in variable.label - ] - return _single(matches, f"primary {signal_prefix}") - - -def _find_reversed( - variables: tuple[TestMqlVariableBinding, ...], - *, - signal_prefix: str, -) -> TestMqlVariableBinding: - matches = [ - variable - for variable in variables - if variable.signal_name.startswith(signal_prefix) - and "sign reversed duplicate" in variable.label - ] - return _single(matches, f"reversed {signal_prefix}") - - -def _find_by_signal( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, -) -> TestMqlVariableBinding: - return _single( - [variable for variable in variables if variable.signal_name == signal_name], - signal_name, - ) - - -def _find_optional_by_signal( - variables: tuple[TestMqlVariableBinding, ...], - signal_name: str, -) -> TestMqlVariableBinding | None: - matches = [variable for variable in variables if variable.signal_name == signal_name] - if not matches: - return None - return _single(matches, signal_name) - - -def _single( - matches: list[TestMqlVariableBinding], - description: str, -) -> TestMqlVariableBinding: - if len(matches) != 1: - raise ValueError(f"Expected one {description} variable, found {len(matches)}.") - return matches[0] diff --git a/app/simulation/reporting/test_mql_output_schema.py b/app/simulation/reporting/test_mql_output_schema.py deleted file mode 100644 index ae24b99..0000000 --- a/app/simulation/reporting/test_mql_output_schema.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from collections import Counter -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_observations import ( - TestMqlObservationCatalog, - build_test_mql_observation_catalog, -) -from app.simulation.reporting.test_mql_variables import TestMqlVariableBinding - - -@dataclass(frozen=True) -class TestMqlOutputSignal: - data_path: str - domain: str - owner_alias: str - owner_kind: str - submodel: str - signal_name: str - units: str | None - amesim_index: int - saved: bool - - -@dataclass(frozen=True) -class TestMqlOutputSchema: - signals: tuple[TestMqlOutputSignal, ...] - - @property - def signal_count(self) -> int: - return len(self.signals) - - def by_data_path(self, data_path: str) -> TestMqlOutputSignal: - for signal in self.signals: - if signal.data_path == data_path: - return signal - raise KeyError(data_path) - - def data_paths(self) -> tuple[str, ...]: - return tuple(signal.data_path for signal in self.signals) - - def data_paths_by_domain(self, domain: str) -> tuple[str, ...]: - return tuple(signal.data_path for signal in self.signals if signal.domain == domain) - - def counts_by_domain(self) -> dict[str, int]: - return dict(Counter(signal.domain for signal in self.signals)) - - def counts_by_submodel(self) -> dict[str, int]: - return dict(Counter(signal.submodel for signal in self.signals)) - - def counts_by_owner_kind(self) -> dict[str, int]: - return dict(Counter(signal.owner_kind for signal in self.signals)) - - def counts_by_units(self) -> dict[str | None, int]: - return dict(Counter(signal.units for signal in self.signals)) - - -def build_test_mql_output_schema( - results: AmesimResults, - *, - observation_catalog: TestMqlObservationCatalog | None = None, -) -> TestMqlOutputSchema: - observation_catalog = observation_catalog or build_test_mql_observation_catalog(results) - domain_by_data_path = _domain_by_data_path(observation_catalog) - signals = [] - for data_path in sorted(domain_by_data_path): - variable = observation_catalog.variable_catalog.by_data_path(data_path) - signals.append(_signal_from_variable(variable, domain_by_data_path[data_path])) - return TestMqlOutputSchema(signals=tuple(signals)) - - -def _domain_by_data_path( - observation_catalog: TestMqlObservationCatalog, -) -> dict[str, str]: - domain_by_data_path = {} - for domain, data_paths in observation_catalog.data_paths_by_domain().items(): - for data_path in data_paths: - if data_path in domain_by_data_path: - raise ValueError(f"Data_Path {data_path!r} is assigned to multiple domains.") - domain_by_data_path[data_path] = domain - return domain_by_data_path - - -def _signal_from_variable( - variable: TestMqlVariableBinding, - domain: str, -) -> TestMqlOutputSignal: - return TestMqlOutputSignal( - data_path=variable.data_path, - domain=domain, - owner_alias=variable.owner_alias, - owner_kind=variable.owner_kind, - submodel=variable.submodel, - signal_name=variable.signal_name, - units=variable.units, - amesim_index=variable.index, - saved=variable.saved, - ) diff --git a/app/simulation/reporting/test_mql_output_validation.py b/app/simulation/reporting/test_mql_output_validation.py deleted file mode 100644 index 9785aa6..0000000 --- a/app/simulation/reporting/test_mql_output_validation.py +++ /dev/null @@ -1,161 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from math import isfinite - -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.test_mql_comparison import ( - TestMqlComparisonResult, - compare_test_mql_series, -) -from app.simulation.reporting.test_mql_output_schema import TestMqlOutputSchema - - -class TestMqlOutputValidationError(ValueError): - """Raised when a Python test_mql output does not satisfy the AMESim output contract.""" - - -@dataclass(frozen=True) -class TestMqlValidatedOutput: - times: tuple[float, ...] - series_by_data_path: dict[str, tuple[float, ...]] - data_paths: tuple[str, ...] - - def series(self, data_path: str) -> tuple[float, ...]: - if data_path not in self.series_by_data_path: - raise KeyError(data_path) - return self.series_by_data_path[data_path] - - -def validate_test_mql_output( - *, - times: tuple[float, ...] | list[float], - series_by_data_path: dict[str, tuple[float, ...] | list[float]], - schema: TestMqlOutputSchema, - data_paths: tuple[str, ...] | list[str] | None = None, - allow_extra_paths: bool = False, - require_all_schema_paths: bool = False, -) -> TestMqlValidatedOutput: - validated_times = _validate_time_axis(times) - selected_paths = _select_paths( - series_by_data_path=series_by_data_path, - schema=schema, - data_paths=data_paths, - allow_extra_paths=allow_extra_paths, - require_all_schema_paths=require_all_schema_paths, - ) - validated_series = { - data_path: _validate_series( - data_path=data_path, - values=series_by_data_path[data_path], - expected_count=len(validated_times), - ) - for data_path in selected_paths - } - return TestMqlValidatedOutput( - times=validated_times, - series_by_data_path=validated_series, - data_paths=selected_paths, - ) - - -def compare_validated_test_mql_output( - *, - times: tuple[float, ...] | list[float], - series_by_data_path: dict[str, tuple[float, ...] | list[float]], - schema: TestMqlOutputSchema, - amesim_results: AmesimResults, - data_paths: tuple[str, ...] | list[str] | None = None, - allow_extra_paths: bool = False, - require_all_schema_paths: bool = False, - relative_floor: float = 1.0e-12, -) -> TestMqlComparisonResult: - validated = validate_test_mql_output( - times=times, - series_by_data_path=series_by_data_path, - schema=schema, - data_paths=data_paths, - allow_extra_paths=allow_extra_paths, - require_all_schema_paths=require_all_schema_paths, - ) - return compare_test_mql_series( - python_times=validated.times, - python_series_by_data_path=validated.series_by_data_path, - amesim_results=amesim_results, - data_paths=validated.data_paths, - relative_floor=relative_floor, - ) - - -def _validate_time_axis(times: tuple[float, ...] | list[float]) -> tuple[float, ...]: - if not times: - raise TestMqlOutputValidationError("Python time axis is empty.") - validated = tuple(_finite_float("time", value) for value in times) - previous = validated[0] - for value in validated[1:]: - if value < previous: - raise TestMqlOutputValidationError("Python time axis must be monotonically increasing.") - previous = value - return validated - - -def _select_paths( - *, - series_by_data_path: dict[str, tuple[float, ...] | list[float]], - schema: TestMqlOutputSchema, - data_paths: tuple[str, ...] | list[str] | None, - allow_extra_paths: bool, - require_all_schema_paths: bool, -) -> tuple[str, ...]: - schema_paths = set(schema.data_paths()) - provided_paths = set(series_by_data_path) - if not allow_extra_paths: - extra_paths = sorted(provided_paths - schema_paths) - if extra_paths: - raise TestMqlOutputValidationError( - f"Python output contains Data_Path values outside test_mql schema: {extra_paths}" - ) - if require_all_schema_paths: - missing_schema_paths = sorted(schema_paths - provided_paths) - if missing_schema_paths: - raise TestMqlOutputValidationError( - f"Python output is missing required test_mql schema Data_Path values: {missing_schema_paths}" - ) - selected_paths = tuple(data_paths) if data_paths is not None else tuple(sorted(provided_paths & schema_paths)) - if not selected_paths: - raise TestMqlOutputValidationError("no test_mql schema Data_Path values are available.") - unknown_selected = [data_path for data_path in selected_paths if data_path not in schema_paths] - if unknown_selected: - raise TestMqlOutputValidationError( - f"Requested Data_Path values are outside test_mql schema: {unknown_selected}" - ) - missing_selected = [data_path for data_path in selected_paths if data_path not in series_by_data_path] - if missing_selected: - raise TestMqlOutputValidationError( - f"Python output is missing selected Data_Path values: {missing_selected}" - ) - return selected_paths - - -def _validate_series( - *, - data_path: str, - values: tuple[float, ...] | list[float], - expected_count: int, -) -> tuple[float, ...]: - if len(values) != expected_count: - raise TestMqlOutputValidationError( - f"Python series length mismatch for {data_path!r}: " - f"{len(values)} values for {expected_count} time samples." - ) - return tuple(_finite_float(data_path, value) for value in values) - - -def _finite_float(label: str, value: float) -> float: - try: - numeric_value = float(value) - except (TypeError, ValueError) as exc: - raise TestMqlOutputValidationError(f"{label!r} contains a non-numeric value: {value!r}") from exc - if not isfinite(numeric_value): - raise TestMqlOutputValidationError(f"{label!r} contains a non-finite value: {value!r}") - return numeric_value diff --git a/app/simulation/reporting/test_mql_variables.py b/app/simulation/reporting/test_mql_variables.py deleted file mode 100644 index 6a120c7..0000000 --- a/app/simulation/reporting/test_mql_variables.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import re -from collections import Counter -from dataclasses import dataclass - -from app.simulation.reporting.amesim_results import AmesimResults, AmesimVariable -from app.simulation.examples.test_mql.system import COMPONENT_SPECS, CONNECTION_SPECS - - -_UNIT_RE = re.compile(r"\[([^\]]+)\]\s*$") - - -@dataclass(frozen=True) -class TestMqlVariableBinding: - index: int - data_path: str - signal_name: str - owner_alias: str - owner_kind: str - submodel: str - label: str - units: str | None - saved: bool - - -@dataclass(frozen=True) -class TestMqlVariableCatalog: - variables: tuple[TestMqlVariableBinding, ...] - - @property - def data_path_count(self) -> int: - return len(self.variables) - - @property - def saved_data_path_count(self) -> int: - return sum(1 for variable in self.variables if variable.saved) - - def by_data_path(self, data_path: str) -> TestMqlVariableBinding: - for variable in self.variables: - if variable.data_path == data_path: - return variable - raise KeyError(data_path) - - def counts_by_submodel(self) -> dict[str, int]: - return dict(Counter(variable.submodel for variable in self.variables)) - - def counts_by_owner_kind(self) -> dict[str, int]: - return dict(Counter(variable.owner_kind for variable in self.variables)) - - def data_paths_for_owner(self, owner_alias: str) -> tuple[str, ...]: - return tuple( - variable.data_path - for variable in self.variables - if variable.owner_alias == owner_alias - ) - - -def build_test_mql_variable_catalog(amesim_results: AmesimResults) -> TestMqlVariableCatalog: - owner_map = _build_owner_map() - saved_indices = set(amesim_results.saved_variable_indices) - bindings = [] - for variable in amesim_results.variables: - if variable.data_path is None: - continue - signal_name, owner_alias = split_data_path(variable.data_path) - owner_kind, submodel = owner_map[owner_alias] - bindings.append( - TestMqlVariableBinding( - index=variable.index, - data_path=variable.data_path, - signal_name=signal_name, - owner_alias=owner_alias, - owner_kind=owner_kind, - submodel=submodel, - label=variable.label, - units=_extract_units(variable), - saved=variable.index in saved_indices, - ) - ) - return TestMqlVariableCatalog(variables=tuple(bindings)) - - -def split_data_path(data_path: str) -> tuple[str, str]: - if "@" not in data_path: - raise ValueError(f"AMESim Data_Path does not contain an owner alias: {data_path!r}") - signal_name, owner_alias = data_path.rsplit("@", 1) - if not signal_name or not owner_alias: - raise ValueError(f"Invalid AMESim Data_Path: {data_path!r}") - return signal_name, owner_alias - - -def _build_owner_map() -> dict[str, tuple[str, str]]: - owner_map = { - str(spec["alias"]): ("component", str(spec["submodel"])) - for spec in COMPONENT_SPECS - } - owner_map.update( - { - str(spec["alias"]): ("connection", str(spec["submodel"])) - for spec in CONNECTION_SPECS - } - ) - return owner_map - - -def _extract_units(variable: AmesimVariable) -> str | None: - match = _UNIT_RE.search(variable.label) - if match is None: - return None - return match.group(1) diff --git a/app/simulation/reporting/testmodel_outputs.py b/app/simulation/reporting/testmodel_outputs.py deleted file mode 100644 index 9338b1c..0000000 --- a/app/simulation/reporting/testmodel_outputs.py +++ /dev/null @@ -1,393 +0,0 @@ -from __future__ import annotations - -from bisect import bisect_left -import csv -from dataclasses import dataclass -from pathlib import Path -from typing import Any - - -PRIMARY_KEYS = ( - "mytank.p", - "mytank.T", - "mycylinder.p", - "mycylinder.T", -) - -MODELICA_COMPARISON_COLUMNS = { - "mytank.p": "mytank.p", - "mytank.T": "mytank.T", - "mycylinder.p": "mycylinder.p", - "mycylinder.T": "mycylinder.T", - "branch.upper_branch.p": "mypipe.p", - "branch.upper_branch.in": "myorifice.port_a.m_flow", - "branch.upper_branch.out": "mytee1.port_out2.m_flow", - "branch.lower_branch.p": "mypipe1.p", - "branch.lower_branch.in": "myorifice1.port_a.m_flow", - "branch.lower_branch.out": "mytee1.port_out1.m_flow", -} - -COMPARISON_KEYS = tuple(MODELICA_COMPARISON_COLUMNS.keys()) - - -def _branch_series_values( - series: dict[str, list[float]], - branch_name: str, - legacy_key: str, -) -> list[float]: - generic_key = f"branch.{branch_name}.{legacy_key.split('.')[-1]}" - if generic_key in series: - return series[generic_key] - return series[legacy_key] - - -@dataclass(frozen=True) -class TestModelArtifacts: - primary_csv_path: Path - temperature_csv_path: Path - temperature_svg_path: Path - run_report_path: Path - comparison_csv_path: Path | None = None - comparison_summary_path: Path | None = None - - -def format_testmodel_run_report( - *, - network_summary: str, - initialization: Any, - raw_initial_state: tuple[float, ...], - consistent_initial_state: tuple[float, ...], - solution: Any, - series: dict[str, list[float]], - solve_diagnostics: Any, - artifacts: TestModelArtifacts, - comparison_summary: dict[str, tuple[float, float]] | None, -) -> str: - lines = [ - network_summary, - "", - f"Initialization converged: {initialization.converged}", - f"Initialization iterations: {initialization.iterations}", - f"Initialization max state delta: {initialization.max_state_delta:.6e}", - f"Initialization max flow delta: {initialization.max_flow_delta:.6e}", - f"Initialization max enthalpy delta: {initialization.max_enthalpy_delta:.6e}", - ( - "Initialization downstream pressure spread: " - f"{initialization.downstream_pressure_spread:.6e}" - ), - "", - "Raw initial state vector:", - str(list(raw_initial_state)), - "", - "Constraint-consistent initial state vector:", - str(list(consistent_initial_state)), - "", - f"Solver success: {solution.success}", - f"Solver message: {solution.message}", - f"Final time: {solution.t[-1]:.2f} s", - f"Final tank pressure: {series['mytank.p'][-1]:.3f} Pa", - f"Final tank temperature: {series['mytank.T'][-1]:.3f} K", - f"Final cylinder pressure: {series['mycylinder.p'][-1]:.3f} Pa", - ( - "Final branch inflow: " - f"{_branch_series_values(series, 'upper_branch', 'branch_upper.in')[-1] + _branch_series_values(series, 'lower_branch', 'branch_lower.in')[-1]:.6f} kg/s" - ), - ] - - if solve_diagnostics is not None: - lines.extend( - [ - "", - "Final closure solve diagnostics:", - ( - "Upper branch inlet solve: " - f"converged={solve_diagnostics.upper_branch_inlet.converged}, " - f"iterations={solve_diagnostics.upper_branch_inlet.iterations}, " - f"residual={solve_diagnostics.upper_branch_inlet.residual:.6e}" - ), - ( - "Lower branch inlet solve: " - f"converged={solve_diagnostics.lower_branch_inlet.converged}, " - f"iterations={solve_diagnostics.lower_branch_inlet.iterations}, " - f"residual={solve_diagnostics.lower_branch_inlet.residual:.6e}" - ), - ] - ) - if solve_diagnostics.downstream_pressure_projection is not None: - lines.append( - "Downstream pressure projection: " - f"converged={solve_diagnostics.downstream_pressure_projection.converged}, " - f"iterations={solve_diagnostics.downstream_pressure_projection.iterations}, " - f"residual={solve_diagnostics.downstream_pressure_projection.residual:.6e}" - ) - - lines.extend( - [ - f"Primary series CSV: {artifacts.primary_csv_path}", - f"Temperature CSV: {artifacts.temperature_csv_path}", - f"Temperature plot: {artifacts.temperature_svg_path}", - f"Run report TXT: {artifacts.run_report_path}", - ] - ) - - if ( - artifacts.comparison_csv_path is not None - and artifacts.comparison_summary_path is not None - ): - lines.extend( - [ - f"Modelica comparison CSV: {artifacts.comparison_csv_path}", - f"Modelica comparison summary: {artifacts.comparison_summary_path}", - ] - ) - if comparison_summary is not None: - for key, (max_abs_error, max_rel_error) in comparison_summary.items(): - lines.append( - f"{key} max abs error: {max_abs_error:.6f}, " - f"max rel error: {max_rel_error:.6%}" - ) - - return "\n".join(lines) + "\n" - - -def write_testmodel_run_report(output_dir: Path, report_text: str) -> Path: - report_path = output_dir / "testmodel_run_report.txt" - report_path.write_text(report_text, encoding="utf-8") - return report_path - - -def _write_primary_series_csv(output_dir: Path, series: dict[str, list[float]]) -> Path: - csv_path = output_dir / "testmodel_primary_series.csv" - with csv_path.open("w", newline="", encoding="utf-8") as handle: - writer = csv.writer(handle) - writer.writerow(["time_s", *PRIMARY_KEYS]) - for index, time_value in enumerate(series["time"]): - writer.writerow([time_value, *(series[key][index] for key in PRIMARY_KEYS)]) - return csv_path - - -def _write_temperature_csv(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path: - csv_path = output_dir / "testmodel_tank_temperature.csv" - with csv_path.open("w", newline="", encoding="utf-8") as handle: - writer = csv.writer(handle) - writer.writerow(["time_s", "mytank_T_K"]) - writer.writerows(zip(time_values, temperatures)) - return csv_path - - -def _write_temperature_svg(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path: - svg_path = output_dir / "testmodel_tank_temperature.svg" - - width = 900 - height = 520 - left = 90 - right = 40 - top = 60 - bottom = 70 - plot_width = width - left - right - plot_height = height - top - bottom - - min_time = min(time_values) - max_time = max(time_values) - min_temp = min(temperatures) - max_temp = max(temperatures) - temp_padding = max(1.0, (max_temp - min_temp) * 0.08) - min_temp -= temp_padding - max_temp += temp_padding - - def scale_x(value: float) -> float: - return left + (value - min_time) / max(max_time - min_time, 1e-12) * plot_width - - def scale_y(value: float) -> float: - return top + (max_temp - value) / max(max_temp - min_temp, 1e-12) * plot_height - - points = " ".join( - f"{scale_x(time_value):.2f},{scale_y(temperature):.2f}" - for time_value, temperature in zip(time_values, temperatures) - ) - - x_ticks = 5 - y_ticks = 5 - x_tick_markup = [] - y_tick_markup = [] - - for index in range(x_ticks + 1): - fraction = index / x_ticks - time_value = min_time + fraction * (max_time - min_time) - x = left + fraction * plot_width - x_tick_markup.append( - f'' - ) - x_tick_markup.append( - f'' - f"{time_value:.1f}" - ) - - for index in range(y_ticks + 1): - fraction = index / y_ticks - temp_value = min_temp + fraction * (max_temp - min_temp) - y = top + plot_height - fraction * plot_height - y_tick_markup.append( - f'' - ) - y_tick_markup.append( - f'' - f"{temp_value:.1f}" - ) - - svg_content = f""" - - Python Testmodel Tank Temperature - Time (s) - Temperature (K) - - {''.join(x_tick_markup)} - {''.join(y_tick_markup)} - - -""" - svg_path.write_text(svg_content, encoding="utf-8") - return svg_path - - -def load_modelica_series(csv_path: Path, variable_names: tuple[str, ...]) -> dict[str, list[float]]: - series = {"time": []} - for variable_name in variable_names: - series[variable_name] = [] - - with csv_path.open("r", newline="", encoding="utf-8") as handle: - reader = csv.DictReader(handle) - available_variable_names = tuple( - variable_name - for variable_name in variable_names - if MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) in (reader.fieldnames or ()) - ) - for row in reader: - series["time"].append(float(row["time"])) - for variable_name in available_variable_names: - modelica_column = MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) - series[variable_name].append(float(row[modelica_column])) - - return series - - -def _interpolate_series_value(time_values: list[float], values: list[float], target_time: float) -> float: - if target_time <= time_values[0]: - return values[0] - if target_time >= time_values[-1]: - return values[-1] - - right_index = bisect_left(time_values, target_time) - if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1e-12: - return values[right_index] - - left_index = right_index - 1 - left_time = time_values[left_index] - right_time = time_values[right_index] - fraction = (target_time - left_time) / (right_time - left_time) - return values[left_index] + fraction * (values[right_index] - values[left_index]) - - -def write_modelica_comparison( - output_dir: Path, - python_series: dict[str, list[float]], - modelica_series: dict[str, list[float]], -) -> tuple[Path, Path, dict[str, tuple[float, float]]]: - comparison_csv_path = output_dir / "testmodel_modelica_comparison.csv" - summary_path = output_dir / "testmodel_modelica_comparison_summary.txt" - summary: dict[str, tuple[float, float]] = {} - - with comparison_csv_path.open("w", newline="", encoding="utf-8") as handle: - writer = csv.writer(handle) - header = ["time_s"] - comparison_keys = tuple( - key - for key in COMPARISON_KEYS - if key in python_series and key in modelica_series and modelica_series[key] - ) - for key in comparison_keys: - header.extend( - [ - f"python.{key}", - f"modelica.{key}", - f"abs_error.{key}", - f"rel_error.{key}", - ] - ) - writer.writerow(header) - - max_abs_errors = {key: 0.0 for key in comparison_keys} - max_rel_errors = {key: 0.0 for key in comparison_keys} - - for index, time_value in enumerate(python_series["time"]): - row = [time_value] - for key in comparison_keys: - python_value = python_series[key][index] - modelica_value = _interpolate_series_value( - modelica_series["time"], - modelica_series[key], - time_value, - ) - abs_error = abs(python_value - modelica_value) - rel_error = abs_error / max(abs(modelica_value), 1e-9) - max_abs_errors[key] = max(max_abs_errors[key], abs_error) - max_rel_errors[key] = max(max_rel_errors[key], rel_error) - row.extend([python_value, modelica_value, abs_error, rel_error]) - writer.writerow(row) - - summary_lines = [] - for key in comparison_keys: - summary[key] = (max_abs_errors[key], max_rel_errors[key]) - summary_lines.append( - f"{key}: max_abs_error={max_abs_errors[key]:.6f}, " - f"max_rel_error={max_rel_errors[key]:.6%}" - ) - summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8") - return comparison_csv_path, summary_path, summary - - -def export_testmodel_artifacts( - *, - output_dir: Path, - series: dict[str, list[float]], - modelica_series: dict[str, list[float]] | None = None, -) -> tuple[TestModelArtifacts, dict[str, tuple[float, float]] | None]: - output_dir.mkdir(parents=True, exist_ok=True) - - primary_csv_path = _write_primary_series_csv(output_dir, series) - temperature_csv_path = _write_temperature_csv( - output_dir, - series["time"], - series["mytank.T"], - ) - temperature_svg_path = _write_temperature_svg( - output_dir, - series["time"], - series["mytank.T"], - ) - - comparison_csv_path = None - comparison_summary_path = None - comparison_summary = None - if modelica_series is not None: - ( - comparison_csv_path, - comparison_summary_path, - comparison_summary, - ) = write_modelica_comparison(output_dir, series, modelica_series) - - return ( - TestModelArtifacts( - primary_csv_path=primary_csv_path, - temperature_csv_path=temperature_csv_path, - temperature_svg_path=temperature_svg_path, - run_report_path=output_dir / "testmodel_run_report.txt", - comparison_csv_path=comparison_csv_path, - comparison_summary_path=comparison_summary_path, - ), - comparison_summary, - ) diff --git a/app/simulation/results.py b/app/simulation/results.py new file mode 100644 index 0000000..452f5e1 --- /dev/null +++ b/app/simulation/results.py @@ -0,0 +1,49 @@ +"""Backend-neutral simulation result and preparation error contracts.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal +from app.simulation.core.metadata import ResultVariableMetadata + +SimulationRunStatus = Literal["completed", "cancelled", "failed"] + +@dataclass(frozen=True) +class SimulationPreparationIssue: + code: str + message: str + + def as_dict(self) -> dict[str, str]: + return {"code": self.code, "message": self.message} + + +class SimulationPreparationError(ValueError): + def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None: + super().__init__("The compiled model is not ready for simulation.") + self.issues = issues + + +@dataclass(frozen=True) +class GenericSimulationResult: + success: bool + status: SimulationRunStatus + message: str + simulated_until: float + requested_stop_time: float + variables: tuple[ResultVariableMetadata, ...] + series: dict[str, list[float]] + final: dict[str, float] + diagnostics: dict[str, object] + + def as_dict(self) -> dict[str, object]: + return { + "success": self.success, + "status": self.status, + "partial": self.status != "completed", + "message": self.message, + "simulatedUntil": self.simulated_until, + "requestedStopTime": self.requested_stop_time, + "variables": [variable.as_dict() for variable in self.variables], + "series": self.series, + "final": self.final, + "diagnostics": self.diagnostics, + } diff --git a/app/simulation/sampling.py b/app/simulation/sampling.py new file mode 100644 index 0000000..c8d6335 --- /dev/null +++ b/app/simulation/sampling.py @@ -0,0 +1,101 @@ +"""Validate the requested output time grid before starting the C worker.""" +from __future__ import annotations +from math import floor, isfinite +from sys import maxsize +from app.simulation.config import SolveIVPConfig + +class SimulationSampleTimeError(ValueError): + """Stable failure contract for an unsafe or unrepresentable sample grid.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + +def simulation_sample_times( + config: SolveIVPConfig, + step: float, +) -> list[float]: + t_start = float(config.t_start) + t_stop = float(config.t_stop) + if not isfinite(t_start) or not isfinite(t_stop): + raise SimulationSampleTimeError( + "SIMULATION_VALUE_NOT_FINITE", + "Simulation start and stop times must be finite.", + ) + if step <= 0.0 or not isfinite(step): + raise SimulationSampleTimeError( + "SIMULATION_SAMPLE_STEP_INVALID", + "Simulation sample step must be finite and greater than zero.", + ) + duration = t_stop - t_start + if not isfinite(duration): + raise SimulationSampleTimeError( + "SIMULATION_TIME_SPAN_NOT_FINITE", + "Simulation time span must be finite.", + ) + if duration <= 0.0: + raise SimulationSampleTimeError( + "SIMULATION_TIME_RANGE_INVALID", + "Simulation stop time must be greater than start time.", + ) + + ratio = duration / step + if not isfinite(ratio): + raise SimulationSampleTimeError( + "SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE", + "Simulation sample count cannot be represented by this runtime; " + "increase sampleStep.", + ) + interval_count = floor(ratio) + # There is no product-level point cap. Still reject a collection that the + # Python runtime cannot index before multiplying by the potentially huge + # interval count or allocating the output grid. + if interval_count > maxsize - 2: + raise SimulationSampleTimeError( + "SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE", + "Simulation sample count cannot be represented by this runtime; " + "increase sampleStep.", + ) + last_regular_time = t_start + interval_count * step + append_stop = last_regular_time < t_stop + requested_point_count = interval_count + 1 + int(append_stop) + if requested_point_count > maxsize: + raise SimulationSampleTimeError( + "SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE", + "Simulation sample count cannot be represented by this runtime; " + "increase sampleStep.", + ) + + times = [t_start] + for index in range(1, interval_count + 1): + candidate = t_start + index * step + if not isfinite(candidate): + raise SimulationSampleTimeError( + "SIMULATION_SAMPLE_TIME_UNREPRESENTABLE", + "Simulation sampleStep cannot be represented over the requested " + "absolute time range.", + ) + if candidate >= t_stop: + candidate = t_stop + if candidate <= times[-1]: + raise SimulationSampleTimeError( + "SIMULATION_SAMPLE_TIME_UNREPRESENTABLE", + "Simulation sampleStep is too small to advance floating-point " + "time over the requested absolute time range.", + ) + times.append(candidate) + if candidate == t_stop: + break + if times[-1] < t_stop: + times.append(t_stop) + + if len(times) < 2 or any( + current >= following + for current, following in zip(times, times[1:]) + ): + raise SimulationSampleTimeError( + "SIMULATION_SAMPLE_TIME_UNREPRESENTABLE", + "Simulation sample times must contain at least two strictly " + "increasing values.", + ) + return times diff --git a/app/simulation/solvers/__init__.py b/app/simulation/solvers/__init__.py deleted file mode 100644 index 8d4841c..0000000 --- a/app/simulation/solvers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Numerical solvers used by simulation systems.""" diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py deleted file mode 100644 index 22fbb0f..0000000 --- a/app/simulation/solvers/algebraic.py +++ /dev/null @@ -1,3332 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping - -from dataclasses import dataclass, replace -from math import expm1, isfinite, log, sqrt -import os - -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.amesim.flow.orifices import ( - AmesimPnor001, - AmesimPnvo001FixedOpening, -) -from app.simulation.components.amesim.flow.pipes import ( - AmesimPnl00r, - AmesimPnl0001, - AmesimPnl0002, -) -from app.simulation.core.equations import EquationResidual -from app.simulation.core.ports import PortState, VariableRole -from app.simulation.performance import profile_phase -from app.simulation.systems.network import SimulationNetwork - - -# The pneumatic constitutive laws require strictly positive absolute pressure. -# Keep the optimizer's open lower bound at zero: adaptive explicit integrators -# can legitimately probe positive sub-pascal trial states before rejecting a -# high-stiffness step. A 1 Pa bound made those otherwise valid seeds fail in -# SciPy before the residuals were evaluated. -PRESSURE_LOWER_BOUND_PA = 0.0 - - -CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_FAST_PATH" -CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_EXECUTOR_V2" -CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = ( - "SIMULATION_CAUSAL_COORDINATE_KERNEL" -) -CAUSAL_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 - - -def _causal_fast_path_environment_enabled() -> bool: - value = os.getenv(CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE, "1") - return value.strip().lower() not in {"0", "false", "no", "off"} - - -def _causal_executor_v2_environment_enabled() -> bool: - """Return whether the allocation-light causal executor is enabled.""" - - value = os.getenv(CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, "1") - return value.strip().lower() not in {"0", "false", "no", "off"} - - -def _causal_coordinate_kernel_environment_enabled() -> bool: - """Return whether the canonical-coordinate causal kernel is enabled.""" - - value = os.getenv(CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE, "1") - return value.strip().lower() not in {"0", "false", "no", "off"} - - -def _causal_direct_sum_assignments_environment_enabled() -> bool: - """Return whether exact sum-to-zero targets bypass component tuples.""" - - value = os.getenv(CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE, "1") - return value.strip().lower() not in {"0", "false", "no", "off"} - - -def _causal_direct_equation_readers_environment_enabled() -> bool: - """Return whether exact-class scalar residual readers are enabled.""" - - value = os.getenv(CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE, "1") - return value.strip().lower() not in {"0", "false", "no", "off"} - - -class AlgebraicSolveError(RuntimeError): - def __init__( - self, - message: str, - diagnostics: "AlgebraicSolveDiagnostics", - *, - scope_kind: str = "network", - scope_components: tuple[str, ...] = (), - ) -> None: - super().__init__(message) - self.diagnostics = diagnostics - self.scope_kind = scope_kind - self.scope_components = scope_components - - -@dataclass(frozen=True) -class AlgebraicUnknown: - component: str - port: str - variable: str - role: VariableRole - state: PortState - - @property - def id(self) -> str: - return f"{self.component}.{self.port}.{self.variable}" - - def read(self) -> float: - return float(getattr(self.state, self.variable)) - - def write(self, value: float) -> None: - setattr(self.state, self.variable, float(value)) - - -@dataclass(frozen=True) -class ExplicitFlowAssignment: - equation_id: str - unknown: AlgebraicUnknown - evaluate: Callable[[], float] | None - component: object | None = None - equation_index: int | None = None - - -@dataclass(frozen=True) -class ExplicitFlowStage: - assignments: tuple[ExplicitFlowAssignment, ...] - direct_evaluations: tuple[tuple[int, Callable[[], float]], ...] - component_evaluations: tuple["ExplicitFlowComponentEvaluation", ...] - - -@dataclass(frozen=True) -class ExplicitFlowComponentEvaluation: - component: object - evaluate: Callable[[], tuple[float, ...]] - assignment_indices: tuple[int, ...] - equation_indices: tuple[int, ...] - equation_ids: tuple[str, ...] - - -@dataclass(frozen=True) -class EquationScalePlan: - variable_names: tuple[str, ...] - force_unknowns: tuple[AlgebraicUnknown, ...] - - -@dataclass(frozen=True) -class EffortAnchor: - unknown: AlgebraicUnknown - evaluate: Callable[[], float] - equation_id: str - causal_evaluate: Callable[[], float] | None = None - - -@dataclass(frozen=True) -class CausalEffortAssignment: - """One uniquely state-anchored effort equality group.""" - - variable: str - members: tuple[AlgebraicUnknown, ...] - anchor: EffortAnchor - - -@dataclass(frozen=True) -class CausalEffortKernelTarget: - """One canonical effort coordinate extracted from a residual evaluator.""" - - coordinate_index: int - assignment: CausalEffortAssignment - equation_index: int - equation_id: str - - -@dataclass(frozen=True) -class CausalEffortKernelEvaluation: - """One component call shared by every state anchor that it owns.""" - - evaluate: Callable[[], tuple[float, ...]] - targets: tuple[CausalEffortKernelTarget, ...] - - -@dataclass(frozen=True) -class CausalEffortKernelStage: - """Precompiled canonical coordinates and compatibility broadcasts.""" - - variable: str - assignments: tuple[tuple[int, CausalEffortAssignment], ...] - direct_targets: tuple[tuple[int, CausalEffortAssignment], ...] - component_evaluations: tuple[CausalEffortKernelEvaluation, ...] - - -@dataclass(frozen=True) -class CausalFlowKernelStage: - """Map one existing dependency stage into the canonical workspace.""" - - stage: ExplicitFlowStage - coordinate_indices: tuple[int, ...] - - -@dataclass(frozen=True) -class ConnectionEquationEvaluation: - template: EquationResidual - evaluate: Callable[[], float] - - -@dataclass(frozen=True) -class ComponentEquationEvaluation: - component: object - evaluate: Callable[[], tuple[float, ...]] - templates: tuple[EquationResidual, ...] - - -@dataclass(frozen=True) -class AlgebraicEquationBlock: - """One connected component of the equation/unknown incidence graph.""" - - unknown_indices: tuple[int, ...] - equation_indices: tuple[int, ...] - - -@dataclass(frozen=True) -class ScopedComponentEquationEvaluation: - evaluate: Callable[[], tuple[float, ...]] - targets: tuple[tuple[int, int, str], ...] - - -@dataclass(frozen=True) -class ScopedConnectionEquationEvaluation: - target: int - evaluate: Callable[[], float] - - -@dataclass(frozen=True) -class AlgebraicEquationSubset: - """Compiled residual evaluation for a union of independent blocks.""" - - unknown_indices: tuple[int, ...] - equation_indices: tuple[int, ...] - component_evaluations: tuple[ScopedComponentEquationEvaluation, ...] - connection_evaluations: tuple[ScopedConnectionEquationEvaluation, ...] - jacobian_sparsity: object - - def equation_values(self) -> tuple[float, ...]: - values: list[float | None] = [None] * len(self.equation_indices) - for evaluation in self.component_evaluations: - component_values = evaluation.evaluate() - for target, source, equation_id in evaluation.targets: - if source >= len(component_values): - raise RuntimeError( - "Compiled algebraic equation disappeared at runtime: " - f"{equation_id}." - ) - values[target] = float(component_values[source]) - for evaluation in self.connection_evaluations: - values[evaluation.target] = float(evaluation.evaluate()) - if any(value is None for value in values): - raise RuntimeError("Scoped algebraic evaluation returned no value.") - return tuple(float(value) for value in values) - - -@dataclass(frozen=True) -class ResistancePnl0001SeriesBinding: - resistance: AmesimPnor001 | AmesimPnvo001FixedOpening - resistance_port: str - resistance_other_port: str - positive_flow_port: str - pipe: AmesimPnl00r | AmesimPnl0001 - pipe_port: str - pipe_other_port: str | None - - -@dataclass(frozen=True) -class ClosedResistancePressureBinding: - component: object - port_name: str - neighbor: object - neighbor_port: str - pressure_source_port: str | None - - -@dataclass(frozen=True) -class EffortEqualityGroup: - variable: str - members: tuple[AlgebraicUnknown, ...] - anchors: tuple[EffortAnchor, ...] - - -@dataclass(frozen=True) -class UnilateralContactBinding: - component: object - algebraic_group: EffortEqualityGroup - neighbor_force: AlgebraicUnknown - algebraic_port: int - force_sign: float - - -@dataclass(frozen=True) -class AlgebraicSolveDiagnostics: - success: bool - message: str - evaluations: int - pressure_scale: float - flow_scale: float - max_scaled_residual: float - max_raw_residual: float - residual_evaluations: int = 0 - jacobian_mode: str = "seeded" - dense_fallback_used: bool = False - nonlinear_block_count: int = 0 - nonlinear_block_unknown_count: int = 0 - block_fallback_used: bool = False - block_fallback_reason: str | None = None - residual_verified_this_solve: bool = True - causal_fast_path_used: bool = False - - def as_dict(self) -> dict[str, object]: - return { - "success": self.success, - "message": self.message, - "evaluations": self.evaluations, - "pressureScale": self.pressure_scale, - "flowScale": self.flow_scale, - "maxScaledResidual": self.max_scaled_residual, - "maxRawResidual": self.max_raw_residual, - "residualEvaluations": self.residual_evaluations, - "jacobianMode": self.jacobian_mode, - "denseFallbackUsed": self.dense_fallback_used, - "nonlinearBlockCount": self.nonlinear_block_count, - "nonlinearBlockUnknownCount": self.nonlinear_block_unknown_count, - "blockFallbackUsed": self.block_fallback_used, - "blockFallbackReason": self.block_fallback_reason, - "residualVerifiedThisSolve": self.residual_verified_this_solve, - "causalFastPathUsed": self.causal_fast_path_used, - } - - -class PressureFlowSolver: - """Solve the acausal pressure-flow subsystem for a compiled network.""" - - def __init__( - self, - network: SimulationNetwork, - *, - residual_tolerance: float = 1e-7, - max_evaluations: int = 500, - scope_kind: str = "network", - ) -> None: - self.network = network - self.residual_tolerance = residual_tolerance - self.max_evaluations = max_evaluations - self.scope_kind = scope_kind - self._causal_direct_sum_assignments_environment_enabled = ( - _causal_direct_sum_assignments_environment_enabled() - ) - self._causal_direct_sum_flow_assignment_count = 0 - self._causal_direct_equation_readers_environment_enabled = ( - _causal_direct_equation_readers_environment_enabled() - ) - self._causal_direct_effort_anchor_count = 0 - self.unknowns = self._build_unknowns() - self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns} - self._unknowns_by_variable = { - variable: tuple( - unknown - for unknown in self.unknowns - if unknown.variable == variable - ) - for variable in ("p", "m_flow", "x", "v", "f") - } - self._component_equation_owners = tuple(network.components.values()) - self._component_equation_plan = tuple( - ComponentEquationEvaluation( - component=component, - evaluate=component.pressure_flow_equation_values, - templates=component.pressure_flow_equation_residuals(), - ) - for component in self._component_equation_owners - ) - self._component_equation_plans_by_id = { - item.component.name: item - for item in self._component_equation_plan - } - self._estimated_flow_components = tuple( - component - for component in self._component_equation_owners - if hasattr(component, "K_eff") - ) - self._causal_contact_components = tuple( - component - for component in self._component_equation_owners - if getattr(component, "clear_causal_contact", None) is not None - ) - self._connection_equation_plan = tuple( - ConnectionEquationEvaluation( - template=equation, - evaluate=self._equation_value_reader(equation), - ) - for equation in network.connection_equation_residuals() - ) - self._equation_templates = tuple( - equation - for item in self._component_equation_plan - for equation in item.templates - ) + tuple(item.template for item in self._connection_equation_plan) - self._causal_direct_equation_readers = ( - self._build_causal_direct_equation_readers() - ) - self._effort_groups = { - variable: self._build_effort_equality_groups(variable) - for variable in ("p", "x", "v") - } - self._resistance_pnl0001_series_plan = ( - self._build_resistance_pnl0001_series_plan() - ) - self._closed_resistance_pressure_plan = ( - self._build_closed_resistance_pressure_plan() - ) - self._unilateral_contact_plan = self._build_unilateral_contact_plan() - self._explicit_flow_plan = self._build_explicit_flow_plan() - self._explicit_flow_plans_by_variables = { - selected: self._filter_explicit_flow_plan(selected) - for selected in (frozenset(("f", "m_flow")), frozenset(("f",))) - } - self._explicit_flow_unknowns_by_variables = { - selected: tuple( - unknown - for unknown in self.unknowns - if unknown.variable in selected - ) - for selected in self._explicit_flow_plans_by_variables - } - self._equation_scale_plans = self._build_equation_scale_plans( - self._equation_templates - ) - ( - self._jacobian_sparsity, - self._jacobian_sparsity_is_trusted, - self._jacobian_sparsity_fallback_reason, - ) = self._build_jacobian_sparsity() - self._equation_evaluation_locations = ( - self._build_equation_evaluation_locations() - ) - ( - self._equation_blocks, - self._equation_blocks_are_trusted, - self._equation_blocks_fallback_reason, - ) = self._build_equation_blocks() - ( - self._causal_effort_plan_by_variable, - self._causal_flow_unknown_ids, - self._causal_flow_equation_ids, - self._causal_fast_path_eligible, - self._causal_fast_path_fallback_reason, - ) = self._build_causal_execution_plan() - self._causal_direct_effort_anchor_count = sum( - assignment.anchor.causal_evaluate is not None - for assignments in self._causal_effort_plan_by_variable.values() - for assignment in assignments - ) - self._causal_fast_path_environment_enabled = ( - _causal_fast_path_environment_enabled() - ) - self._causal_executor_v2_environment_enabled = ( - _causal_executor_v2_environment_enabled() - ) - self._causal_coordinate_kernel_environment_enabled = ( - _causal_coordinate_kernel_environment_enabled() - ) - self._causal_compiled_effort_unknown_count = sum( - len(assignment.members) - for assignments in self._causal_effort_plan_by_variable.values() - for assignment in assignments - ) - self._causal_compiled_flow_assignment_count = sum( - len(stage.assignments) for stage in self._explicit_flow_plan - ) - self._causal_external_effort_unknowns = tuple( - member - for variable in ("x", "v") - for assignment in self._causal_effort_plan_by_variable.get( - variable, () - ) - for member in assignment.members - ) - self._causal_external_x_states = tuple( - unknown.state - for unknown in self._causal_external_effort_unknowns - if unknown.variable == "x" - ) - self._causal_external_v_states = tuple( - unknown.state - for unknown in self._causal_external_effort_unknowns - if unknown.variable == "v" - ) - ( - self._causal_effort_kernel_by_variable, - self._causal_flow_kernel_plan, - self._causal_coordinate_values, - ) = self._compile_causal_coordinate_kernel() - self._causal_logical_effort_coordinate_count = sum( - len(stage.assignments) - for stage in self._causal_effort_kernel_by_variable.values() - ) - self._causal_eliminated_effort_alias_count = max( - self._causal_compiled_effort_unknown_count - - self._causal_logical_effort_coordinate_count, - 0, - ) - self._causal_compatibility_scatter_count = ( - self._causal_compiled_effort_unknown_count - + self._causal_compiled_flow_assignment_count - ) - self._causal_runtime_disabled_reason: str | None = None - self._causal_audit_interval = CAUSAL_FAST_PATH_AUDIT_INTERVAL - self._causal_audit_required = True - self._causal_solves_since_audit = 0 - self._causal_fast_solve_count = 0 - self._causal_full_residual_audit_count = 0 - self._causal_audit_failure_count = 0 - self._causal_legacy_fallback_count = 0 - self._causal_v2_fast_solve_count = 0 - self._causal_v2_runtime_validation_failure_count = 0 - self._causal_coordinate_fast_solve_count = 0 - self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None - self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None - self.last_diagnostics: AlgebraicSolveDiagnostics | None = None - - @property - def equation_templates(self) -> tuple[EquationResidual, ...]: - """Immutable equation metadata compiled for this solver.""" - - return self._equation_templates - - @property - def jacobian_sparsity(self): - """Compiled equation/unknown dependency pattern for finite differences.""" - - return self._jacobian_sparsity - - @property - def jacobian_sparsity_is_trusted(self) -> bool: - """Whether every algebraic row and column has declared structure.""" - - return self._jacobian_sparsity_is_trusted - - @property - def jacobian_sparsity_fallback_reason(self) -> str | None: - """Reason sparse finite differences are disabled for this solver.""" - - return self._jacobian_sparsity_fallback_reason - - @property - def equation_blocks(self) -> tuple[AlgebraicEquationBlock, ...]: - """Trusted connected blocks in the algebraic incidence graph.""" - - return self._equation_blocks - - @property - def equation_blocks_are_trusted(self) -> bool: - """Whether scoped nonlinear fallback is safe for this network.""" - - return self._equation_blocks_are_trusted - - @property - def equation_blocks_fallback_reason(self) -> str | None: - """Reason nonlinear block pruning is disabled for this network.""" - - return self._equation_blocks_fallback_reason - - @property - def causal_fast_path_eligible(self) -> bool: - """Whether the compiled equations form one strictly causal program.""" - - return self._causal_fast_path_eligible - - @property - def causal_fast_path_enabled(self) -> bool: - """Whether new solves may currently use the causal fast path.""" - - return ( - self._causal_fast_path_environment_enabled - and self._causal_fast_path_eligible - and self._causal_runtime_disabled_reason is None - ) - - @property - def causal_executor_v2_enabled(self) -> bool: - """Whether this run may use the v2 executor (environment opt-out).""" - - return ( - self._causal_executor_v2_environment_enabled - and self.causal_fast_path_enabled - ) - - @property - def causal_coordinate_kernel_enabled(self) -> bool: - """Whether the canonical-coordinate executor may run now.""" - - return ( - self._causal_coordinate_kernel_environment_enabled - and self.causal_executor_v2_enabled - and bool(self._causal_coordinate_values) - ) - - def causal_execution_diagnostics(self) -> dict[str, object]: - disabled_reason = self._causal_runtime_disabled_reason - if not self._causal_fast_path_environment_enabled: - disabled_reason = "disabledByEnvironment" - elif not self._causal_fast_path_eligible: - disabled_reason = self._causal_fast_path_fallback_reason - last_verified = self._causal_last_verified_diagnostics - return { - "eligible": self._causal_fast_path_eligible, - "enabled": self.causal_fast_path_enabled, - "fallbackReason": self._causal_fast_path_fallback_reason, - "disabledReason": disabled_reason, - "fastSolveCount": self._causal_fast_solve_count, - "fullResidualAuditCount": self._causal_full_residual_audit_count, - "auditFailureCount": self._causal_audit_failure_count, - "legacyFallbackCount": self._causal_legacy_fallback_count, - "auditInterval": self._causal_audit_interval, - "solvesSinceAudit": self._causal_solves_since_audit, - "lastVerifiedMaxScaledResidual": ( - last_verified.max_scaled_residual - if last_verified is not None - else None - ), - "executorV2Configured": self._causal_executor_v2_environment_enabled, - "executorV2Enabled": self.causal_executor_v2_enabled, - "executorV2FastSolveCount": self._causal_v2_fast_solve_count, - "executorV2RuntimeValidationFailureCount": ( - self._causal_v2_runtime_validation_failure_count - ), - "coordinateKernelConfigured": ( - self._causal_coordinate_kernel_environment_enabled - ), - "coordinateKernelEnabled": self.causal_coordinate_kernel_enabled, - "coordinateKernelFastSolveCount": ( - self._causal_coordinate_fast_solve_count - ), - "directSumAssignmentsConfigured": ( - self._causal_direct_sum_assignments_environment_enabled - ), - "directSumFlowAssignmentCount": ( - self._causal_direct_sum_flow_assignment_count - ), - "directEquationReadersConfigured": ( - self._causal_direct_equation_readers_environment_enabled - ), - "directEffortAnchorCount": ( - self._causal_direct_effort_anchor_count - ), - "compiledEffortUnknownCount": ( - self._causal_compiled_effort_unknown_count - ), - "compiledFlowAssignmentCount": ( - self._causal_compiled_flow_assignment_count - ), - "compiledAssignmentCount": ( - self._causal_compiled_effort_unknown_count - + self._causal_compiled_flow_assignment_count - ), - "logicalEffortCoordinateCount": ( - self._causal_logical_effort_coordinate_count - ), - "eliminatedEffortAliasCount": ( - self._causal_eliminated_effort_alias_count - ), - "canonicalCoordinateCount": len(self._causal_coordinate_values), - "compatibilityScatterCount": ( - self._causal_compatibility_scatter_count - ), - } - - def request_causal_audit(self) -> None: - """Require the next eligible solve to verify every residual.""" - - self._causal_audit_required = True - - def _disable_causal_fast_path(self, reason: str) -> None: - if self._causal_runtime_disabled_reason is None: - self._causal_runtime_disabled_reason = reason - self._causal_audit_required = True - - def _build_causal_execution_plan( - self, - ) -> tuple[ - dict[str, tuple[CausalEffortAssignment, ...]], - frozenset[str], - frozenset[str], - bool, - str | None, - ]: - """Prove that effort propagation plus staged flow assignment is complete. - - The fast path is deliberately narrower than ordinary sparse fallback. - It accepts only audited built-in residual contracts, one state anchor per - effort equality tree, and a flow plan whose dependency stages never use - the arbitrary cycle-breaking seed retained by the compatibility solver. - """ - - empty = ({}, frozenset(), frozenset()) - - def failed(reason: str): - return (*empty, False, reason) - - if self.scope_kind != "network": - return failed("nonGlobalAlgebraicScope") - if not self._jacobian_sparsity_is_trusted: - return failed( - self._jacobian_sparsity_fallback_reason - or "untrustedAlgebraicStructure" - ) - if self._unilateral_contact_plan: - return failed("activeSetCausalizationRequired") - if self._closed_resistance_pressure_plan: - return failed("specialClosedResistancePressureSeed") - if self._resistance_pnl0001_series_plan: - return failed("specialSeriesPressureSeed") - if len(self._unknowns_by_id) != len(self.unknowns): - return failed("duplicateAlgebraicUnknown") - if len({equation.id for equation in self._equation_templates}) != len( - self._equation_templates - ): - return failed("duplicateAlgebraicEquation") - - effort_unknown_ids = frozenset( - unknown.id for unknown in self.unknowns if unknown.role == "effort" - ) - effort_groups = tuple( - group - for variable_groups in self._effort_groups.values() - for group in variable_groups - ) - effort_member_ids = frozenset( - unknown.id for group in effort_groups for unknown in group.members - ) - if effort_member_ids != effort_unknown_ids: - return failed("incompleteEffortGroupCoverage") - if any(len(group.anchors) != 1 for group in effort_groups): - return failed("effortGroupDoesNotHaveOneAnchor") - - group_by_unknown_id = { - unknown.id: group - for group in effort_groups - for unknown in group.members - } - state_equations_by_group = {id(group): 0 for group in effort_groups} - equal_equations_by_group = {id(group): 0 for group in effort_groups} - effort_equations = tuple( - equation - for equation in self._equation_templates - if equation.role == "effort" - ) - for equation in effort_equations: - referenced_ids = tuple( - dict.fromkeys( - variable - for variable in equation.variables - if variable in effort_unknown_ids - ) - ) - if equation.relation == "state": - if len(referenced_ids) != 1: - return failed("invalidEffortStateEquation") - group = group_by_unknown_id[referenced_ids[0]] - if equation.id != group.anchors[0].equation_id: - return failed("effortAnchorEquationMismatch") - state_equations_by_group[id(group)] += 1 - continue - if equation.relation == "equal": - if len(referenced_ids) != 2: - return failed("invalidEffortEqualityEquation") - first_group = group_by_unknown_id[referenced_ids[0]] - second_group = group_by_unknown_id[referenced_ids[1]] - if first_group is not second_group: - return failed("crossGroupEffortEquality") - equal_equations_by_group[id(first_group)] += 1 - continue - return failed("unsupportedEffortEquationRelation") - for group in effort_groups: - if state_equations_by_group[id(group)] != 1: - return failed("invalidEffortStateEquationCount") - if equal_equations_by_group[id(group)] != len(group.members) - 1: - return failed("effortEqualityGroupIsNotATree") - - effort_plan_by_variable = { - variable: tuple( - CausalEffortAssignment( - variable=variable, - members=group.members, - anchor=group.anchors[0], - ) - for group in self._effort_groups[variable] - ) - for variable in ("p", "x", "v") - } - - flow_unknown_ids = frozenset( - unknown.id for unknown in self.unknowns if unknown.role == "flow" - ) - flow_equations = tuple( - equation - for equation in self._equation_templates - if equation.role == "flow" - ) - if any( - equation.relation not in {"constitutive", "sumToZero"} - for equation in flow_equations - ): - return failed("unsupportedFlowEquationRelation") - if len(effort_equations) + len(flow_equations) != len( - self._equation_templates - ): - return failed("unsupportedAlgebraicEquationRole") - - equation_by_id = { - equation.id: equation for equation in self._equation_templates - } - assigned_unknown_ids: list[str] = [] - assigned_equation_ids: list[str] = [] - seeded_unknown_ids: set[str] = set() - for stage in self._explicit_flow_plan: - stage_unknown_ids: set[str] = set() - for assignment in stage.assignments: - equation = equation_by_id.get(assignment.equation_id) - if equation is None or equation.role != "flow": - return failed("unknownExplicitFlowEquation") - dependency_ids = { - unknown.id - for unknown in self._flow_unknowns_for_equation(equation) - } - if dependency_ids - seeded_unknown_ids != {assignment.unknown.id}: - return failed("cyclicExplicitFlowPlan") - if ( - assignment.unknown.id in seeded_unknown_ids - or assignment.unknown.id in stage_unknown_ids - ): - return failed("duplicateExplicitFlowAssignment") - stage_unknown_ids.add(assignment.unknown.id) - assigned_unknown_ids.append(assignment.unknown.id) - assigned_equation_ids.append(assignment.equation_id) - seeded_unknown_ids.update(stage_unknown_ids) - - flow_equation_ids = frozenset( - equation.id for equation in flow_equations - ) - if ( - frozenset(assigned_unknown_ids) != flow_unknown_ids - or len(assigned_unknown_ids) != len(flow_unknown_ids) - ): - return failed("incompleteExplicitFlowCoverage") - if ( - frozenset(assigned_equation_ids) != flow_equation_ids - or len(assigned_equation_ids) != len(flow_equation_ids) - ): - return failed("incompleteExplicitFlowEquationCoverage") - return ( - effort_plan_by_variable, - flow_unknown_ids, - flow_equation_ids, - True, - None, - ) - - def _compile_causal_coordinate_kernel( - self, - ) -> tuple[ - dict[str, CausalEffortKernelStage], - tuple[CausalFlowKernelStage, ...], - list[float], - ]: - """Compile independent coordinates without changing public port state. - - ``PortState`` remains the compatibility surface consumed by component - methods. The workspace stores one value per proven effort equality - group and one per explicit flow assignment; compatibility aliases are - populated only after every target in an effort stage has been checked. - """ - - if not self._causal_fast_path_eligible: - return {}, (), [] - - equation_index_by_id = { - equation.id: index - for index, equation in enumerate(self._equation_templates) - } - effort_stages: dict[str, CausalEffortKernelStage] = {} - next_coordinate = 0 - for variable in ("p", "x", "v"): - assignments = self._causal_effort_plan_by_variable.get(variable, ()) - indexed_assignments = tuple( - (next_coordinate + offset, assignment) - for offset, assignment in enumerate(assignments) - ) - next_coordinate += len(indexed_assignments) - direct_targets: list[tuple[int, CausalEffortAssignment]] = [] - targets_by_component: dict[ - int, - list[CausalEffortKernelTarget], - ] = {} - component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {} - for coordinate_index, assignment in indexed_assignments: - if assignment.anchor.causal_evaluate is not None: - direct_targets.append((coordinate_index, assignment)) - continue - equation_index = equation_index_by_id[ - assignment.anchor.equation_id - ] - kind, evaluation_plan, source = ( - self._equation_evaluation_locations[equation_index] - ) - if kind != "component": - direct_targets.append((coordinate_index, assignment)) - continue - component_plan = evaluation_plan - key = id(component_plan) - component_evaluators[key] = component_plan.evaluate - targets_by_component.setdefault(key, []).append( - CausalEffortKernelTarget( - coordinate_index=coordinate_index, - assignment=assignment, - equation_index=source, - equation_id=assignment.anchor.equation_id, - ) - ) - effort_stages[variable] = CausalEffortKernelStage( - variable=variable, - assignments=indexed_assignments, - direct_targets=tuple(direct_targets), - component_evaluations=tuple( - CausalEffortKernelEvaluation( - evaluate=component_evaluators[key], - targets=tuple(targets), - ) - for key, targets in targets_by_component.items() - ), - ) - - flow_stages: list[CausalFlowKernelStage] = [] - for stage in self._explicit_flow_plan: - coordinate_indices = tuple( - range(next_coordinate, next_coordinate + len(stage.assignments)) - ) - next_coordinate += len(stage.assignments) - flow_stages.append( - CausalFlowKernelStage( - stage=stage, - coordinate_indices=coordinate_indices, - ) - ) - return effort_stages, tuple(flow_stages), [0.0] * next_coordinate - - @staticmethod - def _read_effort_anchor(assignment: CausalEffortAssignment) -> float: - state = assignment.anchor.unknown.state - if assignment.variable == "p": - return state.p - if assignment.variable == "x": - return state.x - return state.v - - @staticmethod - def _scatter_effort_assignment( - assignment: CausalEffortAssignment, - value: float, - ) -> None: - if assignment.variable == "p": - for unknown in assignment.members: - unknown.state.p = value - return - if assignment.variable == "x": - for unknown in assignment.members: - unknown.state.x = value - return - for unknown in assignment.members: - unknown.state.v = value - - def _execute_causal_coordinate_effort_plan( - self, - variables: tuple[str, ...], - ) -> bool: - """Evaluate canonical effort coordinates in component-sized batches.""" - - workspace = self._causal_coordinate_values - for variable in variables: - stage = self._causal_effort_kernel_by_variable.get(variable) - if stage is None: - return False - for coordinate_index, assignment in stage.direct_targets: - evaluate = ( - assignment.anchor.causal_evaluate - if assignment.anchor.causal_evaluate is not None - else assignment.anchor.evaluate - ) - try: - workspace[coordinate_index] = ( - self._read_effort_anchor(assignment) - - evaluate() - ) - except MemoryError: - raise - except Exception: - if assignment.anchor.causal_evaluate is None: - raise - return False - for evaluation in stage.component_evaluations: - equation_values = evaluation.evaluate() - for target in evaluation.targets: - if target.equation_index >= len(equation_values): - raise RuntimeError( - "Compiled algebraic equation disappeared at runtime: " - f"{target.equation_id}." - ) - workspace[target.coordinate_index] = ( - self._read_effort_anchor(target.assignment) - - float(equation_values[target.equation_index]) - ) - for coordinate_index, assignment in stage.assignments: - target = workspace[coordinate_index] - if not isfinite(target) or ( - variable == "p" and target <= PRESSURE_LOWER_BOUND_PA - ): - return False - for coordinate_index, assignment in stage.assignments: - self._scatter_effort_assignment( - assignment, - workspace[coordinate_index], - ) - return True - - def _execute_causal_effort_plan( - self, - variables: tuple[str, ...], - ) -> bool: - if self.causal_coordinate_kernel_enabled: - return self._execute_causal_coordinate_effort_plan(variables) - for variable in variables: - assignments = self._causal_effort_plan_by_variable.get(variable) - if assignments is None: - return False - for assignment in assignments: - anchor = assignment.anchor - evaluate = ( - anchor.causal_evaluate - if anchor.causal_evaluate is not None - else anchor.evaluate - ) - try: - target = anchor.unknown.read() - evaluate() - except MemoryError: - raise - except Exception: - if anchor.causal_evaluate is None: - raise - return False - if not isfinite(target) or ( - variable == "p" and target <= PRESSURE_LOWER_BOUND_PA - ): - return False - for unknown in assignment.members: - unknown.write(target) - return True - - def _causal_audit_is_due(self) -> bool: - return ( - self._causal_audit_required - or self._causal_last_verified_diagnostics is None - or self._causal_solves_since_audit >= self._causal_audit_interval - ) - - def _record_causal_audit( - self, - diagnostics: AlgebraicSolveDiagnostics, - ) -> None: - self._causal_full_residual_audit_count += 1 - self._causal_solves_since_audit = 0 - self._causal_audit_required = False - self._causal_last_verified_diagnostics = diagnostics - self._causal_cached_fast_diagnostics = replace( - diagnostics, - message=( - "Compiled causal pressure-flow program completed; residuals " - "reuse the latest full audit." - ), - evaluations=0, - residual_evaluations=0, - dense_fallback_used=False, - nonlinear_block_count=0, - nonlinear_block_unknown_count=0, - block_fallback_used=False, - block_fallback_reason=None, - residual_verified_this_solve=False, - causal_fast_path_used=True, - ) - - def _causal_fast_diagnostics( - self, - ) -> AlgebraicSolveDiagnostics: - cached = self._causal_cached_fast_diagnostics - if cached is None: - raise RuntimeError("Causal execution has no verified residual baseline.") - return cached - - def _build_jacobian_sparsity(self): - """Compile the residual dependency contract into one CSR pattern. - - Component authoring requires ``EquationResidual.variables`` to list - every algebraic port value read by the residual. A missing row/column - or a reference to a physical algebraic variable that is not present in - this solver makes that contract structurally incomplete, so nonlinear - fallback stays on the legacy dense finite-difference path. - """ - - try: - from scipy.sparse import csr_matrix - except ImportError as exc: - raise RuntimeError( - "Topology-driven simulation requires SciPy; install requirements.txt." - ) from exc - - unknown_indices = { - unknown.id: index for index, unknown in enumerate(self.unknowns) - } - row_indices: list[int] = [] - column_indices: list[int] = [] - declared_algebraic_names = {"p", "m_flow", "x", "v", "f"} - unresolved_algebraic_reference = False - for row_index, equation in enumerate(self._equation_templates): - for variable in equation.variables: - unknown = self._unknowns_by_id.get(variable) - if unknown is not None: - row_indices.append(row_index) - column_indices.append(unknown_indices[unknown.id]) - continue - parts = variable.rsplit(".", 2) - if len(parts) == 3 and parts[-1] in declared_algebraic_names: - unresolved_algebraic_reference = True - - pattern = csr_matrix( - ( - [True] * len(row_indices), - (row_indices, column_indices), - ), - shape=(len(self._equation_templates), len(self.unknowns)), - dtype=bool, - ) - if unresolved_algebraic_reference: - return pattern, False, "unresolvedAlgebraicVariable" - if pattern.shape[0] and any(pattern.getnnz(axis=1) == 0): - return pattern, False, "equationWithoutDeclaredUnknown" - if pattern.shape[1] and any(pattern.getnnz(axis=0) == 0): - return pattern, False, "unknownWithoutDeclaredEquation" - if any( - not type(item.component).__module__.startswith( - "app.simulation.components." - ) - for item in self._component_equation_plan - ): - # Built-in component declarations are covered by the repository's - # structural and numerical dependency tests. An external component - # may omit a read while still leaving every row/column non-empty, - # which cannot be detected from shape checks alone. Keep such - # networks on the original dense finite-difference contract. - return pattern, False, "untrustedCustomComponent" - return pattern, True, None - - def _build_equation_evaluation_locations( - self, - ) -> tuple[tuple[str, object, int], ...]: - locations: list[tuple[str, object, int]] = [] - for component_plan in self._component_equation_plan: - locations.extend( - ("component", component_plan, local_index) - for local_index, _template in enumerate(component_plan.templates) - ) - locations.extend( - ("connection", connection_plan, 0) - for connection_plan in self._connection_equation_plan - ) - if len(locations) != len(self._equation_templates): - raise RuntimeError("Compiled algebraic evaluation plan is inconsistent.") - return tuple(locations) - - def _build_equation_blocks( - self, - ) -> tuple[tuple[AlgebraicEquationBlock, ...], bool, str | None]: - """Partition a trusted square incidence graph into exact blocks. - - A scoped optimizer calls component equation evaluators directly. Keep - that optimization limited to the audited component package: an - external/custom component may have undeclared reads or evaluation side - effects even when its structural metadata happens to look complete. - """ - - if not self._jacobian_sparsity_is_trusted: - return ( - (), - False, - self._jacobian_sparsity_fallback_reason - or "untrustedAlgebraicStructure", - ) - if any( - not type(item.component).__module__.startswith( - "app.simulation.components." - ) - for item in self._component_equation_plan - ): - return (), False, "untrustedCustomComponent" - - unknown_index_by_id = { - unknown.id: index for index, unknown in enumerate(self.unknowns) - } - if len(unknown_index_by_id) != len(self.unknowns): - return (), False, "duplicateAlgebraicUnknown" - - equation_unknowns: list[tuple[int, ...]] = [] - equations_by_unknown: list[list[int]] = [ - [] for _unknown in self.unknowns - ] - for equation_index, equation in enumerate(self._equation_templates): - dependencies = tuple( - dict.fromkeys( - unknown_index_by_id[variable] - for variable in equation.variables - if variable in unknown_index_by_id - ) - ) - if not dependencies: - return (), False, "equationWithoutDeclaredUnknown" - equation_unknowns.append(dependencies) - for unknown_index in dependencies: - equations_by_unknown[unknown_index].append(equation_index) - if any(not attached for attached in equations_by_unknown): - return (), False, "unknownWithoutDeclaredEquation" - - blocks: list[AlgebraicEquationBlock] = [] - visited_unknowns: set[int] = set() - visited_equations: set[int] = set() - for root_unknown in range(len(self.unknowns)): - if root_unknown in visited_unknowns: - continue - block_unknowns: set[int] = set() - block_equations: set[int] = set() - pending_unknowns = [root_unknown] - while pending_unknowns: - unknown_index = pending_unknowns.pop() - if unknown_index in visited_unknowns: - continue - visited_unknowns.add(unknown_index) - block_unknowns.add(unknown_index) - for equation_index in equations_by_unknown[unknown_index]: - if equation_index not in visited_equations: - visited_equations.add(equation_index) - block_equations.add(equation_index) - for dependency in equation_unknowns[equation_index]: - if dependency not in visited_unknowns: - pending_unknowns.append(dependency) - unknown_indices = tuple(sorted(block_unknowns)) - equation_indices = tuple(sorted(block_equations)) - if len(unknown_indices) != len(equation_indices): - return (), False, "nonSquareEquationBlock" - blocks.append( - AlgebraicEquationBlock( - unknown_indices=unknown_indices, - equation_indices=equation_indices, - ) - ) - if len(visited_equations) != len(self._equation_templates): - return (), False, "unreachableEquationBlock" - return tuple(blocks), True, None - - def _compile_equation_subset( - self, - blocks: tuple[AlgebraicEquationBlock, ...], - ) -> AlgebraicEquationSubset: - """Compile one batched residual for the union of independent blocks.""" - - equation_indices = tuple( - sorted( - equation_index - for block in blocks - for equation_index in block.equation_indices - ) - ) - unknown_indices = tuple( - sorted( - unknown_index - for block in blocks - for unknown_index in block.unknown_indices - ) - ) - equation_target = { - equation_index: target - for target, equation_index in enumerate(equation_indices) - } - component_targets: dict[int, list[tuple[int, int, str]]] = {} - component_plans: dict[int, ComponentEquationEvaluation] = {} - connection_evaluations: list[ScopedConnectionEquationEvaluation] = [] - for equation_index in equation_indices: - kind, evaluation_plan, source = self._equation_evaluation_locations[ - equation_index - ] - target = equation_target[equation_index] - if kind == "component": - key = id(evaluation_plan) - component_plan = evaluation_plan - component_plans[key] = component_plan - component_targets.setdefault(key, []).append( - ( - target, - source, - self._equation_templates[equation_index].id, - ) - ) - else: - connection_plan = evaluation_plan - connection_evaluations.append( - ScopedConnectionEquationEvaluation( - target=target, - evaluate=connection_plan.evaluate, - ) - ) - component_evaluations = tuple( - ScopedComponentEquationEvaluation( - evaluate=component_plans[key].evaluate, - targets=tuple(targets), - ) - for key, targets in component_targets.items() - ) - jacobian_sparsity = self._jacobian_sparsity[ - list(equation_indices), : - ][:, list(unknown_indices)].tocsr() - if ( - any(jacobian_sparsity.getnnz(axis=1) == 0) - or any(jacobian_sparsity.getnnz(axis=0) == 0) - ): - raise RuntimeError("Scoped algebraic Jacobian is structurally empty.") - return AlgebraicEquationSubset( - unknown_indices=unknown_indices, - equation_indices=equation_indices, - component_evaluations=component_evaluations, - connection_evaluations=tuple(connection_evaluations), - jacobian_sparsity=jacobian_sparsity, - ) - - def scale_context(self) -> dict[str, float]: - """Capture global normalization data for a compatible block solve.""" - - scales = self._scales() - positive_pressures = [ - unknown.read() - for unknown in self._unknowns_by_variable["p"] - if unknown.read() > 0.0 - ] - scales["fallback_pressure"] = ( - sum(positive_pressures) / len(positive_pressures) - if positive_pressures - else scales["p"] - ) - return scales - - def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]: - unknowns: list[AlgebraicUnknown] = [] - for component in self.network.components.values(): - for definition in component.active_port_definitions: - if definition.kind != "physical": - continue - state = component.get_port(definition.name) - for variable in definition.variables: - if variable.role not in {"effort", "flow"}: - continue - unknowns.append( - AlgebraicUnknown( - component=component.name, - port=definition.name, - variable=variable.name, - role=variable.role, - state=state, - ) - ) - return tuple(unknowns) - - @staticmethod - def _port_key(variable: str, expected_variable: str) -> tuple[str, str] | None: - try: - component_name, port_name, variable_name = variable.rsplit(".", 2) - except ValueError: - return None - if variable_name != expected_variable: - return None - return component_name, port_name - - def _seed_equal_efforts( - self, - variables: tuple[str, ...] = ("p", "x", "v"), - ) -> None: - """Lift state-owned efforts across their complete equality groups. - - Dynamic components refresh their own ports before each closure, while - connected algebraic ports retain values from the preceding RHS - evaluation. State equations expose the current effort as - ``port.variable - target``; use that target as the authoritative anchor - for every connected/equal pressure, displacement, and velocity port - before evaluating explicit flow laws. - """ - - self.propagate_equal_efforts(variables) - - def propagate_equal_efforts(self, variables: tuple[str, ...]) -> None: - """Propagate selected state-owned efforts without solving flows. - - Piston geometry needs current mechanical ``x``/``v`` before swept - volume propagation, but pressure and flow equations can wait until the - connected chamber has refreshed that volume. - """ - unknown = sorted(set(variables) - set(self._effort_groups)) - if unknown: - raise ValueError("Unsupported effort variables: " + ", ".join(unknown)) - if self.causal_coordinate_kernel_enabled: - if self._execute_causal_coordinate_effort_plan(variables): - return - self._disable_causal_fast_path("nonFiniteCausalEffortAnchor") - for variable in variables: - self._seed_equal_effort(variable) - - def _build_effort_equality_groups( - self, - variable: str, - ) -> tuple[EffortEqualityGroup, ...]: - effort_unknowns = { - (unknown.component, unknown.port): unknown - for unknown in self.unknowns - if unknown.variable == variable - } - if not effort_unknowns: - return () - - parent = {key: key for key in effort_unknowns} - - def find(key: tuple[str, str]) -> tuple[str, str]: - root = key - while parent[root] != root: - root = parent[root] - while parent[key] != key: - next_key = parent[key] - parent[key] = root - key = next_key - return root - - def union(first: tuple[str, str], second: tuple[str, str]) -> None: - first_root = find(first) - second_root = find(second) - if first_root != second_root: - parent[second_root] = first_root - - for connection in self.network.connections: - if connection.kind != "physical": - continue - first = connection.endpoint_a.key - second = connection.endpoint_b.key - if first in effort_unknowns and second in effort_unknowns: - union(first, second) - - component_equations = { - item.component.name: item.templates - for item in self._component_equation_plan - } - for equations in component_equations.values(): - for equation in equations: - if equation.relation != "equal" or equation.role != "effort": - continue - endpoints = [ - endpoint - for equation_variable in equation.variables - if ( - (endpoint := self._port_key(equation_variable, variable)) - in effort_unknowns - ) - ] - for endpoint in endpoints[1:]: - union(endpoints[0], endpoint) - - members_by_root: dict[tuple[str, str], list[AlgebraicUnknown]] = {} - for endpoint in effort_unknowns: - members_by_root.setdefault(find(endpoint), []).append( - effort_unknowns[endpoint] - ) - - anchors_by_root: dict[tuple[str, str], list[EffortAnchor]] = {} - for equations in component_equations.values(): - for equation in equations: - if equation.relation != "state" or equation.role != "effort": - continue - endpoints = [ - endpoint - for equation_variable in equation.variables - if ( - (endpoint := self._port_key(equation_variable, variable)) - in effort_unknowns - ) - ] - if len(endpoints) != 1: - continue - endpoint = endpoints[0] - unknown = effort_unknowns[endpoint] - anchors_by_root.setdefault(find(endpoint), []).append( - EffortAnchor( - unknown=unknown, - evaluate=self._equation_value_reader(equation), - equation_id=equation.id, - causal_evaluate=( - self._causal_direct_equation_readers.get(equation.id) - ), - ) - ) - - return tuple( - EffortEqualityGroup( - variable=variable, - members=tuple(members), - anchors=tuple(anchors_by_root.get(root, ())), - ) - for root, members in members_by_root.items() - ) - - def _seed_equal_effort(self, variable: str) -> None: - for group in self._effort_groups[variable]: - members = group.members - anchors = tuple( - (anchor.unknown, anchor.unknown.read() - anchor.evaluate()) - for anchor in group.anchors - ) - anchors = tuple( - (unknown, value) - for unknown, value in anchors - if isfinite(value) - ) - if anchors: - # Keep each state-owned port current even when an invalid model - # has conflicting anchors in one equality group. - for unknown, target_value in anchors: - unknown.write(target_value) - anchor_values = [value for _unknown, value in anchors] - effort_scale = max([abs(value) for value in anchor_values] + [1.0]) - if max(anchor_values) - min(anchor_values) > 1.0e-9 * effort_scale: - # A conflicting multi-storage group is structurally invalid; - # leave it for the residual solver/preparation diagnostics. - continue - target_value = sum(anchor_values) / len(anchor_values) - for unknown in members: - unknown.write(target_value) - continue - - if variable == "p": - seed = next( - ( - unknown.read() - for unknown in members - if unknown.read() > 0.0 - ), - None, - ) - if seed is None: - continue - else: - seed = members[0].read() - for unknown in members: - if variable != "p" or unknown.read() <= 0.0: - unknown.write(seed) - - def _connected_flow_unknown( - self, - component_name: str, - port_name: str, - variable: str, - ) -> AlgebraicUnknown | None: - endpoint_key = (component_name, port_name) - for connection in self.network.connections: - if connection.kind != "physical": - continue - if connection.endpoint_a.key == endpoint_key: - other = connection.endpoint_b - elif connection.endpoint_b.key == endpoint_key: - other = connection.endpoint_a - else: - continue - return self._unknowns_by_id.get( - f"{other.component}.{other.port}.{variable}" - ) - return None - - @staticmethod - def _bisect_contact_root( - value_at, - lower: float, - upper: float, - target: float, - ) -> float | None: - lower_value = float(value_at(lower)) - target - upper_value = float(value_at(upper)) - target - tolerance = 1.0e-13 * max(abs(target), 1.0) - if abs(lower_value) <= tolerance: - return lower - if abs(upper_value) <= tolerance: - return upper - if not isfinite(lower_value) or not isfinite(upper_value): - return None - if (lower_value < 0.0) == (upper_value < 0.0): - return None - for _iteration in range(100): - middle = 0.5 * (lower + upper) - middle_value = float(value_at(middle)) - target - if abs(middle_value) <= tolerance: - return middle - if (lower_value < 0.0) == (middle_value < 0.0): - lower = middle - lower_value = middle_value - else: - upper = middle - upper_value = middle_value - return 0.5 * (lower + upper) - - def _contact_penetration_for_force( - self, - component, - requested_force: float, - current_penetration: float, - ) -> float | None: - """Invert one LSTP force law and select the root nearest its current state.""" - - if not isfinite(requested_force): - return None - option = int(getattr(component, "discContactOption", 2.0)) - if option != 1: - requested_force = max(requested_force, 0.0) - stiffness = max(float(getattr(component, "kcont", 0.0)), 0.0) - damping = max(float(getattr(component, "rcont", 0.0)), 0.0) - damping_length = float(getattr(component, "Pdis", 0.0)) - relative_velocity = float(getattr(component, "penetration_velocity")) - damping_term = damping * relative_velocity - current_penetration = ( - max(float(current_penetration), 0.0) - if isfinite(current_penetration) - else 0.0 - ) - force_tolerance = 1.0e-12 * max(abs(requested_force), 1.0) - - def raw_force(penetration: float) -> float: - if penetration <= 0.0: - return 0.0 - damping_fraction = ( - -expm1(-penetration / damping_length) - if damping_length > 0.0 - else 1.0 - ) - return stiffness * penetration + damping_term * damping_fraction - - def contact_force(penetration: float) -> float: - force = raw_force(penetration) - return force if option == 1 else max(force, 0.0) - - candidates: list[float] = [] - - def add_candidate(penetration: float | None) -> None: - if penetration is None or not isfinite(penetration) or penetration < 0.0: - return - if abs(contact_force(penetration) - requested_force) > force_tolerance: - return - if not any( - abs(penetration - candidate) - <= 1.0e-12 * max(abs(penetration), abs(candidate), 1.0e-18) - for candidate in candidates - ): - candidates.append(penetration) - - add_candidate(current_penetration) - add_candidate(0.0) - if option != 1 and requested_force == 0.0: - return min( - candidates or [0.0], - key=lambda penetration: abs(penetration - current_penetration), - ) - - if damping_length <= 0.0: - if stiffness > 0.0: - penetration = (requested_force - damping_term) / stiffness - if penetration > 0.0: - add_candidate(penetration) - elif abs(requested_force - damping_term) <= force_tolerance: - add_candidate(max(current_penetration, 1.0e-18)) - elif stiffness > 0.0: - critical_penetration: float | None = None - if damping_term < -stiffness * damping_length: - critical_penetration = damping_length * log( - -damping_term / (stiffness * damping_length) - ) - add_candidate(critical_penetration) - - upper = max( - current_penetration, - damping_length, - abs(requested_force) / stiffness, - critical_penetration or 0.0, - 1.0e-18, - ) - for _iteration in range(100): - upper_value = raw_force(upper) - if isfinite(upper_value) and upper_value >= requested_force: - break - upper *= 2.0 - else: - upper = float("nan") - - if isfinite(upper): - if critical_penetration is not None: - add_candidate( - self._bisect_contact_root( - raw_force, - 0.0, - critical_penetration, - requested_force, - ) - ) - add_candidate( - self._bisect_contact_root( - raw_force, - critical_penetration, - upper, - requested_force, - ) - ) - else: - add_candidate( - self._bisect_contact_root( - raw_force, - 0.0, - upper, - requested_force, - ) - ) - elif damping_term != 0.0: - upper = max(current_penetration, damping_length, 1.0e-18) - for _iteration in range(100): - upper_value = raw_force(upper) - crossed = ( - upper_value >= requested_force - if damping_term > 0.0 - else upper_value <= requested_force - ) - if isfinite(upper_value) and crossed: - add_candidate( - self._bisect_contact_root( - raw_force, - 0.0, - upper, - requested_force, - ) - ) - break - upper *= 2.0 - - if not candidates: - return None - return min( - candidates, - key=lambda penetration: abs(penetration - current_penetration), - ) - - def _apply_unilateral_contact_binding( - self, - binding: UnilateralContactBinding, - ) -> bool: - component = binding.component - requested_force = binding.force_sign * binding.neighbor_force.read() - if int(getattr(component, "discContactOption", 2.0)) != 1: - requested_force = max(requested_force, 0.0) - cached_penetration = getattr(component, "_causal_penetration", None) - penetration = self._contact_penetration_for_force( - component, - requested_force, - ( - float(cached_penetration) - if cached_penetration is not None - else float(getattr(component, "penetration")) - ), - ) - if penetration is None: - component.clear_causal_contact() - return False - - gap0 = float(getattr(component, "gap0", 0.0)) - if binding.algebraic_port == 1: - target = component.port_2.x + gap0 + penetration - else: - target = component.port_1.x - gap0 - penetration - for unknown in binding.algebraic_group.members: - unknown.write(target) - component.set_causal_contact( - penetration=penetration, - force=requested_force, - ) - return True - - def _refresh_unilateral_contacts( - self, - bindings: tuple[UnilateralContactBinding, ...], - ) -> None: - for binding in bindings: - self._apply_unilateral_contact_binding(binding) - - def _build_unilateral_contact_plan( - self, - ) -> tuple[UnilateralContactBinding, ...]: - """Compile contacts that can eliminate one algebraic coordinate.""" - - position_groups = { - unknown.id: group - for group in self._effort_groups["x"] - for unknown in group.members - } - bindings: list[UnilateralContactBinding] = [] - for component in self.network.components.values(): - if component.model_type != "amesim_lstp00a": - continue - first_neighbor = self._connected_flow_unknown( - component.name, - "port_1", - "f", - ) - second_neighbor = self._connected_flow_unknown( - component.name, - "port_2", - "f", - ) - first_group = position_groups.get(f"{component.name}.port_1.x") - second_group = position_groups.get(f"{component.name}.port_2.x") - if ( - first_group is None - or second_group is None - or first_group is second_group - ): - continue - if not first_group.anchors and first_neighbor is not None: - binding = UnilateralContactBinding( - component=component, - algebraic_group=first_group, - neighbor_force=first_neighbor, - algebraic_port=1, - force_sign=-1.0, - ) - elif not second_group.anchors and second_neighbor is not None: - binding = UnilateralContactBinding( - component=component, - algebraic_group=second_group, - neighbor_force=second_neighbor, - algebraic_port=2, - force_sign=1.0, - ) - else: - # With both coordinates state-owned, penetration is a dynamic - # result rather than an algebraic active-set choice. - continue - bindings.append(binding) - - return tuple(bindings) - - def _seed_unilateral_contacts( - self, - ) -> tuple[UnilateralContactBinding, ...]: - """Apply compiled local contact eliminations for the current state.""" - - bindings: list[UnilateralContactBinding] = [] - bound_group_ids: set[int] = set() - for binding in self._unilateral_contact_plan: - group_id = id(binding.algebraic_group) - if group_id in bound_group_ids: - # One relative contact law may eliminate a free coordinate. - # Any other contact sharing that coordinate must remain in the - # nonlinear system or the projections would overwrite each - # other and make root selection order-dependent. - continue - if self._apply_unilateral_contact_binding(binding): - bindings.append(binding) - bound_group_ids.add(group_id) - - return tuple(bindings) - - def _flow_unknowns_for_equation(self, equation) -> tuple[AlgebraicUnknown, ...]: - return tuple( - self._unknowns_by_id[variable] - for variable in equation.variables - if variable in self._unknowns_by_id - and self._unknowns_by_id[variable].role == "flow" - ) - - def _equation_value_reader(self, equation) -> Callable[[], float]: - if equation.owner == "connection": - if len(equation.variables) != 2: - raise ValueError( - f"Connection equation {equation.id} must contain two variables." - ) - first = self._unknowns_by_id[equation.variables[0]] - second = self._unknowns_by_id[equation.variables[1]] - if equation.relation == "sumToZero": - return lambda: first.read() + second.read() - if equation.relation == "equal": - return lambda: first.read() - second.read() - raise ValueError( - f"Unsupported connection equation relation: {equation.relation}." - ) - - evaluation = self._component_equation_plans_by_id[equation.owner_id] - component = evaluation.component - equation_id = equation.id - equation_ids = tuple(current.id for current in evaluation.templates) - try: - equation_index = equation_ids.index(equation_id) - except ValueError as exc: - raise RuntimeError( - f"Compiled algebraic equation disappeared at runtime: {equation_id}." - ) from exc - - def read_component_equation() -> float: - current_values = evaluation.evaluate() - if equation_index >= len(current_values): - raise RuntimeError( - "Compiled algebraic equation disappeared at runtime: " - f"{equation_id}." - ) - return float(current_values[equation_index]) - - return read_component_equation - - def _build_causal_direct_equation_readers( - self, - ) -> dict[str, Callable[[], float]]: - """Compile exact-class scalar residual capabilities, failing closed.""" - - if not self._causal_direct_equation_readers_environment_enabled: - return {} - - readers: dict[str, Callable[[], float]] = {} - for evaluation in self._component_equation_plan: - component = evaluation.component - # A subclass must repeat the declaration after changing equation - # semantics; inherited purity promises are deliberately ignored. - declared = type(component).__dict__.get( - "pressure_flow_equation_value_readers" - ) - if not callable(declared): - continue - try: - component_readers = declared(component) - except MemoryError: - raise - except Exception: - continue - if not isinstance(component_readers, Mapping): - continue - template_ids = frozenset( - template.id for template in evaluation.templates - ) - if any( - not isinstance(equation_id, str) - or equation_id not in template_ids - or not callable(reader) - for equation_id, reader in component_readers.items() - ): - continue - readers.update(component_readers) - return readers - - def _connection_flow_target_reader( - self, - equation, - unknown: AlgebraicUnknown, - ) -> Callable[[], float]: - if equation.relation != "sumToZero" or len(equation.variables) != 2: - raise ValueError( - f"Connection flow equation {equation.id} must sum two variables." - ) - first = self._unknowns_by_id[equation.variables[0]] - second = self._unknowns_by_id[equation.variables[1]] - if unknown is first: - return lambda: 0.0 - (0.0 + second.read()) - if unknown is second: - return lambda: 0.0 - (first.read() + 0.0) - raise ValueError( - f"Connection flow equation {equation.id} does not contain {unknown.id}." - ) - - def _sum_to_zero_flow_target_reader( - self, - equation, - unknown: AlgebraicUnknown, - ) -> Callable[[], float]: - """Compile the exact target of a declared flow sum without callbacks.""" - - if equation.relation != "sumToZero": - raise ValueError( - f"Equation {equation.id} is not a sum-to-zero relation." - ) - if len(set(equation.variables)) != len(equation.variables): - raise ValueError( - f"Equation {equation.id} repeats a sum-to-zero variable." - ) - equation_unknowns: list[AlgebraicUnknown] = [] - for variable in equation.variables: - current = self._unknowns_by_id.get(variable) - if current is None or current.role != "flow": - raise ValueError( - f"Equation {equation.id} has a non-flow variable." - ) - equation_unknowns.append(current) - if not any(current is unknown for current in equation_unknowns): - raise ValueError( - f"Equation {equation.id} does not contain {unknown.id}." - ) - compiled_unknowns = tuple(equation_unknowns) - if len(compiled_unknowns) == 2: - first, second = compiled_unknowns - return lambda: 0.0 - (first.read() + second.read()) - - def target() -> float: - # The target unknown is already zeroed by the stage executor. Keep - # its slot in the sum so the floating-point operation order matches - # the declared built-in residual, including near cancellation. - return 0.0 - sum(current.read() for current in compiled_unknowns) - - return target - - @staticmethod - def _component_declares_exact_sum_to_zero_equation( - component: object, - equation_id: str, - ) -> bool: - """Accept only an exact concrete-class promise for callback bypass.""" - - declared_suffixes = type(component).__dict__.get( - "PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES" - ) - if not isinstance(declared_suffixes, frozenset) or any( - not isinstance(suffix, str) or not suffix or ":" in suffix - for suffix in declared_suffixes - ): - return False - component_name = getattr(component, "name", None) - if not isinstance(component_name, str): - return False - return any( - equation_id == f"{component_name}:{suffix}" - for suffix in declared_suffixes - ) - - def _pressure_flow_equation_values(self) -> tuple[float, ...]: - """Evaluate live equation values through the compiled topology.""" - - values: list[float] = [] - for item in self._component_equation_plan: - current_values = item.evaluate() - if len(current_values) != len(item.templates): - raise RuntimeError( - "Compiled algebraic equation count changed at runtime for " - f"{item.component.name}." - ) - values.extend(current_values) - values.extend(item.evaluate() for item in self._connection_equation_plan) - return tuple(values) - - def _pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - """Materialize public residual objects only when explicitly requested.""" - - return tuple( - EquationResidual( - id=template.id, - owner=template.owner, - owner_id=template.owner_id, - relation=template.relation, - variables=template.variables, - value=value, - role=template.role, - ) - for template, value in zip( - self._equation_templates, - self._pressure_flow_equation_values(), - ) - ) - - def _explicit_flow_assignment( - self, - equation, - unknown: AlgebraicUnknown, - ) -> ExplicitFlowAssignment: - if equation.owner == "connection": - return ExplicitFlowAssignment( - equation_id=equation.id, - unknown=unknown, - evaluate=self._connection_flow_target_reader(equation, unknown), - ) - - evaluation = self._component_equation_plans_by_id[equation.owner_id] - component = evaluation.component - if ( - self._causal_direct_sum_assignments_environment_enabled - and equation.relation == "sumToZero" - and self._component_declares_exact_sum_to_zero_equation( - component, - equation.id, - ) - ): - try: - evaluate = self._sum_to_zero_flow_target_reader( - equation, - unknown, - ) - except ValueError: - pass - else: - self._causal_direct_sum_flow_assignment_count += 1 - return ExplicitFlowAssignment( - equation_id=equation.id, - unknown=unknown, - evaluate=evaluate, - ) - - equations = evaluation.templates - equation_ids = tuple(current.id for current in equations) - try: - equation_index = equation_ids.index(equation.id) - except ValueError as exc: - raise RuntimeError( - f"Compiled algebraic equation disappeared at runtime: {equation.id}." - ) from exc - return ExplicitFlowAssignment( - equation_id=equation.id, - unknown=unknown, - evaluate=None, - component=component, - equation_index=equation_index, - ) - - def _build_explicit_flow_plan(self) -> tuple[ExplicitFlowStage, ...]: - """Compile flow causalization into independent dependency stages.""" - - stages: list[ExplicitFlowStage] = [] - seeded_ids: set[str] = set() - - initial_assignments: list[ExplicitFlowAssignment] = [] - for evaluation in self._component_equation_plan: - for equation in evaluation.templates: - if equation.relation != "constitutive" or equation.role != "flow": - continue - flow_unknowns = self._flow_unknowns_for_equation(equation) - if len(flow_unknowns) != 1: - continue - unknown = flow_unknowns[0] - if unknown.id in seeded_ids: - continue - initial_assignments.append( - self._explicit_flow_assignment(equation, unknown) - ) - seeded_ids.add(unknown.id) - if initial_assignments: - stages.append(self._compile_explicit_flow_stage(tuple(initial_assignments))) - - equations = self._equation_templates - while True: - stage_assignments: list[ExplicitFlowAssignment] = [] - stage_unknown_ids: set[str] = set() - for equation in equations: - if equation.role != "flow" or equation.relation not in { - "constitutive", - "sumToZero", - }: - continue - flow_unknowns = self._flow_unknowns_for_equation(equation) - if not flow_unknowns: - continue - if len({unknown.variable for unknown in flow_unknowns}) != 1: - continue - unseeded = tuple( - unknown - for unknown in flow_unknowns - if unknown.id not in seeded_ids - ) - if len(unseeded) != 1: - continue - unknown = unseeded[0] - if unknown.id in stage_unknown_ids: - continue - stage_assignments.append( - self._explicit_flow_assignment(equation, unknown) - ) - stage_unknown_ids.add(unknown.id) - if stage_assignments: - stages.append( - self._compile_explicit_flow_stage(tuple(stage_assignments)) - ) - seeded_ids.update(stage_unknown_ids) - continue - - fallback_assignment: ExplicitFlowAssignment | None = None - for equation in equations: - if equation.role != "flow" or equation.relation not in { - "constitutive", - "sumToZero", - }: - continue - flow_unknowns = self._flow_unknowns_for_equation(equation) - unseeded = tuple( - unknown - for unknown in flow_unknowns - if unknown.id not in seeded_ids - ) - if len(unseeded) <= 1: - continue - if len({unknown.variable for unknown in flow_unknowns}) != 1: - continue - unknown = unseeded[-1] - fallback_assignment = self._explicit_flow_assignment( - equation, - unknown, - ) - seeded_ids.add(unknown.id) - break - if fallback_assignment is None: - break - stages.append(self._compile_explicit_flow_stage((fallback_assignment,))) - - return tuple(stages) - - @staticmethod - def _compile_explicit_flow_stage( - assignments: tuple[ExplicitFlowAssignment, ...], - ) -> ExplicitFlowStage: - direct_evaluations: list[tuple[int, Callable[[], float]]] = [] - assignments_by_component: dict[ - object, - list[tuple[int, ExplicitFlowAssignment]], - ] = {} - for assignment_index, assignment in enumerate(assignments): - if assignment.component is None: - if assignment.evaluate is None: - raise RuntimeError( - "Explicit flow assignment has no compiled evaluator: " - f"{assignment.equation_id}." - ) - direct_evaluations.append((assignment_index, assignment.evaluate)) - continue - assignments_by_component.setdefault(assignment.component, []).append( - (assignment_index, assignment) - ) - - component_evaluations: list[ExplicitFlowComponentEvaluation] = [] - for component, component_assignments in assignments_by_component.items(): - if any( - assignment.equation_index is None - for _assignment_index, assignment in component_assignments - ): - raise RuntimeError( - "Explicit component flow assignment has no equation index." - ) - component_evaluations.append( - ExplicitFlowComponentEvaluation( - component=component, - evaluate=component.pressure_flow_equation_values, - assignment_indices=tuple( - assignment_index - for assignment_index, _assignment in component_assignments - ), - equation_indices=tuple( - int(assignment.equation_index) - for _assignment_index, assignment in component_assignments - ), - equation_ids=tuple( - assignment.equation_id - for _assignment_index, assignment in component_assignments - ), - ) - ) - return ExplicitFlowStage( - assignments=assignments, - direct_evaluations=tuple(direct_evaluations), - component_evaluations=tuple(component_evaluations), - ) - - def _filter_explicit_flow_plan( - self, - selected: frozenset[str], - ) -> tuple[ExplicitFlowStage, ...]: - return tuple( - self._compile_explicit_flow_stage( - tuple( - assignment - for assignment in stage.assignments - if assignment.unknown.variable in selected - ) - ) - for stage in self._explicit_flow_plan - ) - - @staticmethod - def _evaluate_explicit_flow_stage( - stage: ExplicitFlowStage, - ) -> tuple[float, ...]: - """Evaluate simultaneous targets while this stage's unknowns are zero.""" - assignments = stage.assignments - values: list[float | None] = [None] * len(assignments) - for assignment_index, evaluate in stage.direct_evaluations: - values[assignment_index] = evaluate() - - for evaluation in stage.component_evaluations: - equation_values = evaluation.evaluate() - for assignment_index, equation_index, equation_id in zip( - evaluation.assignment_indices, - evaluation.equation_indices, - evaluation.equation_ids, - ): - if equation_index >= len(equation_values): - raise RuntimeError( - "Compiled algebraic equation disappeared at runtime: " - f"{equation_id}." - ) - values[assignment_index] = 0.0 - float( - equation_values[equation_index] - ) - if any(value is None for value in values): - raise RuntimeError("Explicit flow evaluation plan returned no value.") - return tuple(float(value) for value in values) - - def _solve_explicit_flow_unknowns( - self, - variables: tuple[str, ...] = ("f", "m_flow"), - ) -> set[str]: - """Execute staged flow/force assignments without repeated equations.""" - - selected = frozenset(variables) - reset_unknowns = self._explicit_flow_unknowns_by_variables.get(selected) - if reset_unknowns is None: - reset_unknowns = tuple( - unknown - for unknown in self.unknowns - if unknown.variable in selected - ) - for unknown in reset_unknowns: - unknown.write(0.0) - - seeded_ids: set[str] = set() - plan = self._explicit_flow_plans_by_variables.get(selected) - if plan is None: - plan = self._filter_explicit_flow_plan(selected) - for stage in plan: - assignments = stage.assignments - values = self._evaluate_explicit_flow_stage(stage) - for assignment, target_value in zip(assignments, values): - if not isfinite(target_value): - continue - assignment.unknown.write(target_value) - seeded_ids.add(assignment.unknown.id) - return seeded_ids - - def _execute_compiled_causal_flow_plan(self) -> str | None: - """Execute the compile-proven full flow plan without coverage sets.""" - - if self.causal_coordinate_kernel_enabled: - return self._execute_causal_coordinate_flow_plan() - - # Position and velocity are propagated by the mechanical reducer - # before the pressure-only causal solve. They are therefore not - # rewritten below, but remain part of the compiled algebraic contract. - # Validate that small external boundary explicitly instead of restoring - # the legacy scan over every pressure/flow/force unknown. - if any( - not isfinite(unknown.read()) - for unknown in self._causal_external_effort_unknowns - ): - return "nonFiniteCausalExternalEffort" - - reset_unknowns = self._explicit_flow_unknowns_by_variables[ - frozenset(("f", "m_flow")) - ] - for unknown in reset_unknowns: - unknown.write(0.0) - - for stage in self._explicit_flow_plan: - try: - values = self._evaluate_explicit_flow_stage(stage) - except MemoryError: - raise - except (ArithmeticError, RuntimeError, ValueError) as exc: - return f"causalFlowEvaluationFailed:{type(exc).__name__}" - if len(values) != len(stage.assignments): - return "causalFlowAssignmentCountMismatch" - for assignment, target_value in zip(stage.assignments, values): - if not isfinite(target_value): - return "nonFiniteCausalFlowAssignment" - assignment.unknown.write(target_value) - return None - - def _execute_causal_coordinate_flow_plan(self) -> str | None: - """Run flow stages through reusable canonical coordinates.""" - - if any(not isfinite(state.x) for state in self._causal_external_x_states): - return "nonFiniteCausalExternalEffort" - if any(not isfinite(state.v) for state in self._causal_external_v_states): - return "nonFiniteCausalExternalEffort" - - return self._execute_causal_coordinate_flow_stages( - self._causal_flow_kernel_plan, - self._causal_coordinate_values, - ) - - @staticmethod - def _execute_causal_coordinate_flow_stages( - kernel_plan: tuple[CausalFlowKernelStage, ...], - workspace: list[float], - ) -> str | None: - """Execute proven flow stages without per-call result containers.""" - - # Residual-based explicit assignments use ``-residual`` and therefore - # require their target coordinate to be zero. Keep this compatibility - # initialization until a component exposes a proven direct target op. - for kernel_stage in kernel_plan: - for assignment in kernel_stage.stage.assignments: - if assignment.unknown.variable == "m_flow": - assignment.unknown.state.m_flow = 0.0 - else: - assignment.unknown.state.f = 0.0 - - for kernel_stage in kernel_plan: - stage = kernel_stage.stage - coordinate_indices = kernel_stage.coordinate_indices - if len(coordinate_indices) != len(stage.assignments): - return "causalFlowAssignmentCountMismatch" - try: - for assignment_index, evaluate in stage.direct_evaluations: - workspace[coordinate_indices[assignment_index]] = float( - evaluate() - ) - for evaluation in stage.component_evaluations: - equation_values = evaluation.evaluate() - for assignment_index, equation_index, equation_id in zip( - evaluation.assignment_indices, - evaluation.equation_indices, - evaluation.equation_ids, - ): - if equation_index >= len(equation_values): - raise RuntimeError( - "Compiled algebraic equation disappeared at " - f"runtime: {equation_id}." - ) - workspace[coordinate_indices[assignment_index]] = ( - 0.0 - float(equation_values[equation_index]) - ) - except MemoryError: - raise - except (ArithmeticError, RuntimeError, ValueError) as exc: - return f"causalFlowEvaluationFailed:{type(exc).__name__}" - for assignment, coordinate_index in zip( - stage.assignments, - coordinate_indices, - ): - target_value = workspace[coordinate_index] - if not isfinite(target_value): - return "nonFiniteCausalFlowAssignment" - for assignment, coordinate_index in zip( - stage.assignments, - coordinate_indices, - ): - target_value = workspace[coordinate_index] - if assignment.unknown.variable == "m_flow": - assignment.unknown.state.m_flow = target_value - else: - assignment.unknown.state.f = target_value - return None - - def _build_closed_resistance_pressure_plan( - self, - ) -> tuple[ClosedResistancePressureBinding, ...]: - """Compile sealed resistance ends whose zero-flow pressure is known.""" - - bindings: list[ClosedResistancePressureBinding] = [] - connected: dict[tuple[str, str], tuple[str, str]] = {} - for connection in self.network.connections: - if connection.kind != "physical" or connection.domain != "pneumatic": - continue - first, second = connection.endpoints - connected[first.key] = second.key - connected[second.key] = first.key - - for component in self.network.components.values(): - if not isinstance(component, (AmesimPnl00r, AmesimPnl0001)): - continue - for port_name in component.ports: - neighbor_key = connected.get((component.name, port_name)) - if neighbor_key is None: - continue - neighbor = self.network.components[neighbor_key[0]] - if not isinstance(neighbor, AmesimPnpl01): - continue - if isinstance(component, AmesimPnl0002): - pressure_source_port = None - elif isinstance(component, AmesimPnl0001): - if port_name != "port_1": - continue - pressure_source_port = None - else: - pressure_source_port = ( - "port_2" if port_name == "port_1" else "port_1" - ) - bindings.append( - ClosedResistancePressureBinding( - component=component, - port_name=port_name, - neighbor=neighbor, - neighbor_port=neighbor_key[1], - pressure_source_port=pressure_source_port, - ) - ) - return tuple(bindings) - - def _seed_closed_resistance_pressures(self) -> None: - """Seed a sealed resistance end at its zero-flow pressure. - - A PNPL01 fixes flow, not pressure. Starting a dead-ended Darcy branch - with the plug-side pressure at the medium reference can otherwise put - the nonlinear solver on the singular square-root part of the inverse - flow law. At zero flow, these AMESim pipe resistances have exactly zero - pressure drop, which gives a deterministic and physically exact seed. - """ - - for binding in self._closed_resistance_pressure_plan: - component = binding.component - pressure = ( - component.properties().p - if binding.pressure_source_port is None - else component.get_port(binding.pressure_source_port).p - ) - component.get_port(binding.port_name).p = pressure - binding.neighbor.get_port(binding.neighbor_port).p = pressure - - def _build_resistance_pnl0001_series_plan( - self, - ) -> tuple[ResistancePnl0001SeriesBinding, ...]: - bindings: list[ResistancePnl0001SeriesBinding] = [] - resistance_types = (AmesimPnor001, AmesimPnvo001FixedOpening) - pipe_types = (AmesimPnl00r, AmesimPnl0001) - for connection in self.network.connections: - first_endpoint, second_endpoint = connection.endpoints - first = self.network.components[first_endpoint.component] - second = self.network.components[second_endpoint.component] - if isinstance(first, resistance_types) and isinstance( - second, pipe_types - ): - resistance, resistance_port = first, first_endpoint.port - pipe, pipe_port = second, second_endpoint.port - elif isinstance(second, resistance_types) and isinstance( - first, pipe_types - ): - resistance, resistance_port = second, second_endpoint.port - pipe, pipe_port = first, first_endpoint.port - else: - continue - if isinstance(pipe, AmesimPnl0001): - if isinstance(pipe, AmesimPnl0002) or pipe_port != "port_1": - continue - pipe_other_port = None - else: - pipe_other_port = ( - "port_2" if pipe_port == "port_1" else "port_1" - ) - if isinstance(resistance, AmesimPnor001): - positive_flow_port, negative_flow_port = "port_1", "port_2" - else: - positive_flow_port, negative_flow_port = "port_2", "port_3" - if resistance_port == positive_flow_port: - resistance_other_port = negative_flow_port - elif resistance_port == negative_flow_port: - resistance_other_port = positive_flow_port - else: - continue - bindings.append( - ResistancePnl0001SeriesBinding( - resistance=resistance, - resistance_port=resistance_port, - resistance_other_port=resistance_other_port, - positive_flow_port=positive_flow_port, - pipe=pipe, - pipe_port=pipe_port, - pipe_other_port=pipe_other_port, - ) - ) - return tuple(bindings) - - def _seed_resistance_pnl0001_series_pressures(self) -> None: - """Causalize pressure between an orifice/valve and a PNL pipe.""" - - from scipy.optimize import brentq - - for binding in self._resistance_pnl0001_series_plan: - resistance = binding.resistance - resistance_port = binding.resistance_port - pipe = binding.pipe - pipe_port = binding.pipe_port - - pressure_a = resistance.get_port(binding.resistance_other_port).p - if isinstance(pipe, AmesimPnl00r): - assert binding.pipe_other_port is not None - pressure_b = pipe.get_port(binding.pipe_other_port).p - pipe_temperature = None - else: - pipe_properties = pipe.properties() - pressure_b = pipe_properties.p - pipe_temperature = pipe_properties.T - lower = min(pressure_a, pressure_b) - upper = max(pressure_a, pressure_b) - - def mismatch(intermediate_pressure: float) -> float: - if resistance_port == binding.positive_flow_port: - resistance_flow_into_connection = resistance.mass_flow( - intermediate_pressure, - pressure_a, - ) - else: - resistance_flow_into_connection = -resistance.mass_flow( - pressure_a, - intermediate_pressure, - ) - if isinstance(pipe, AmesimPnl00r): - pipe.get_port(pipe_port).p = intermediate_pressure - if pipe_port == "port_1": - pipe_flow_into_connection = pipe.mass_flow( - intermediate_pressure, - pressure_b, - ) - else: - pipe_flow_into_connection = -pipe.mass_flow( - pressure_b, - intermediate_pressure, - ) - else: - assert pipe_temperature is not None - pipe_flow_into_connection = pipe.mass_flow( - intermediate_pressure, - pressure_b, - pipe_temperature, - ) - return resistance_flow_into_connection + pipe_flow_into_connection - - lower_value = mismatch(lower) - upper_value = mismatch(upper) - if lower_value == 0.0: - pressure = lower - elif upper_value == 0.0: - pressure = upper - elif (lower_value < 0.0) == (upper_value < 0.0): - continue - else: - pressure = float( - brentq( - mismatch, - lower, - upper, - xtol=1.0e-6, - rtol=1.0e-12, - maxiter=32, - ) - ) - resistance.get_port(resistance_port).p = pressure - pipe.get_port(pipe_port).p = pressure - - def _equation_scale_plan(self, equation) -> EquationScalePlan: - return EquationScalePlan( - variable_names=tuple( - variable.rsplit(".", 1)[-1] - for variable in equation.variables - ), - force_unknowns=tuple( - unknown - for variable in equation.variables - if (unknown := self._unknowns_by_id.get(variable)) is not None - and unknown.variable == "f" - ), - ) - - def _build_equation_scale_plans( - self, - equations: tuple[EquationResidual, ...], - ) -> tuple[EquationScalePlan, ...]: - return tuple( - self._equation_scale_plan(equation) - for equation in equations - ) - - def _scales(self) -> dict[str, float]: - pressure_values = [ - unknown.read() for unknown in self._unknowns_by_variable["p"] - ] - pressure_scale = max( - [ - abs(value) - for value in pressure_values - if value > 0.0 - ] - + [1e5] - ) - estimated_flows = [ - abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale) - for component in self._estimated_flow_components - ] - mass_flow_scale = max( - estimated_flows - + [ - abs(unknown.read()) - for unknown in self._unknowns_by_variable["m_flow"] - ] - + [1e-3] - ) - return { - "p": pressure_scale, - "m_flow": mass_flow_scale, - "x": max( - [abs(unknown.read()) for unknown in self._unknowns_by_variable["x"]] - + [1.0] - ), - "v": max( - [abs(unknown.read()) for unknown in self._unknowns_by_variable["v"]] - + [1.0] - ), - "f": max( - [abs(unknown.read()) for unknown in self._unknowns_by_variable["f"]] - + [1.0] - ), - } - - @profile_phase("simulation.pressure_flow", minimum_mode="audit") - def solve( - self, - *, - effort_variables: tuple[str, ...] = ("p", "x", "v"), - scale_context: Mapping[str, float] | None = None, - ) -> AlgebraicSolveDiagnostics: - try: - import numpy as np - from scipy.optimize import least_squares - except ImportError as exc: - raise RuntimeError( - "Topology-driven simulation requires SciPy; install requirements.txt." - ) from exc - - causal_candidate = ( - self.causal_fast_path_enabled - and effort_variables == ("p",) - and scale_context is None - ) - causal_audit_due = ( - self._causal_audit_is_due() if causal_candidate else False - ) - causal_v2_candidate = ( - causal_candidate and self._causal_executor_v2_environment_enabled - ) - for component in self._causal_contact_components: - component.clear_causal_contact() - - if causal_candidate: - causal_efforts_are_valid = self._execute_causal_effort_plan( - effort_variables - ) - if not causal_efforts_are_valid: - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path("nonFiniteCausalEffortAnchor") - causal_candidate = False - causal_audit_due = False - causal_v2_candidate = False - self._seed_equal_efforts(effort_variables) - else: - self._seed_equal_efforts(effort_variables) - - seeded_flow_ids: set[str] | None = None - contact_bindings: tuple[UnilateralContactBinding, ...] = () - if causal_v2_candidate: - v2_failure_reason = self._execute_compiled_causal_flow_plan() - if v2_failure_reason is not None: - self._causal_v2_runtime_validation_failure_count += 1 - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path(v2_failure_reason) - causal_candidate = False - causal_audit_due = False - causal_v2_candidate = False - # Rebuild the ordinary seed from scratch in the same solve. - # A partial compiled stage must never influence fallback. - self._seed_equal_efforts(effort_variables) - if not causal_v2_candidate: - self._seed_closed_resistance_pressures() - self._seed_resistance_pnl0001_series_pressures() - seeded_flow_ids = self._solve_explicit_flow_unknowns() - contact_bindings = self._seed_unilateral_contacts() - if contact_bindings: - seeded_flow_ids.update( - self._solve_explicit_flow_unknowns(("f",)) - ) - self._refresh_unilateral_contacts(contact_bindings) - if causal_candidate: - if causal_v2_candidate: - # Compilation proves a disjoint, complete effort/flow - # partition. The v2 executors validate each produced value, - # so no coverage set or full unknown scan is needed here. - causal_unknowns_are_feasible = True - else: - assert seeded_flow_ids is not None - causal_unknowns_are_feasible = ( - not contact_bindings - and seeded_flow_ids == self._causal_flow_unknown_ids - and all( - isfinite(unknown.read()) - and ( - unknown.variable != "p" - or unknown.read() > PRESSURE_LOWER_BOUND_PA - ) - for unknown in self.unknowns - ) - ) - if not causal_unknowns_are_feasible: - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path("causalRuntimeGateFailed") - causal_candidate = False - causal_audit_due = False - elif not causal_audit_due: - diagnostics = self._causal_fast_diagnostics() - self._causal_fast_solve_count += 1 - if causal_v2_candidate: - self._causal_v2_fast_solve_count += 1 - if self.causal_coordinate_kernel_enabled: - self._causal_coordinate_fast_solve_count += 1 - self._causal_solves_since_audit += 1 - self.last_diagnostics = diagnostics - return diagnostics - scales = ( - { - name: float(scale_context[name]) - for name in ("p", "m_flow", "x", "v", "f") - } - if scale_context is not None - else self._scales() - ) - pressure_scale = scales["p"] - flow_scale = scales["m_flow"] - seeded_values = self._pressure_flow_equation_values() - - def initial_equation_scale( - equation: EquationResidual, - value: float, - scale_plan: EquationScalePlan, - ) -> float: - variable_names = scale_plan.variable_names - if equation.role == "flow": - force_scales = [ - max(abs(unknown.read()), 1.0) - for unknown in scale_plan.force_unknowns - ] - if force_scales: - # Freeze force scaling per equation. A 1e17 N source must - # not hide an unrelated 40 N piston/contact imbalance in a - # different mechanical branch. - return max(force_scales + [abs(value), 1.0]) - return flow_scale - if equation.role == "effort": - if "x" in variable_names: - return scales["x"] - if "v" in variable_names: - return scales["v"] - return pressure_scale - return max([scales.get(name, 1.0) for name in variable_names] + [1.0]) - - equation_scales = tuple( - initial_equation_scale(equation, value, scale_plan) - for equation, value, scale_plan in zip( - self._equation_templates, - seeded_values, - self._equation_scale_plans, - ) - ) - - seeded_scaled = [ - abs(value / scale) - for value, scale in zip(seeded_values, equation_scales) - ] - seeded_max_scaled_residual = max(seeded_scaled, default=0.0) - seeded_unknown_values = [ - (unknown, unknown.read()) for unknown in self.unknowns - ] - seeded_unknowns_are_feasible = all( - isfinite(value) - and ( - unknown.variable != "p" - or value > PRESSURE_LOWER_BOUND_PA - ) - for unknown, value in seeded_unknown_values - ) - seeded_state_is_valid = ( - seeded_unknowns_are_feasible - and all(isfinite(value) for value in seeded_scaled) - and seeded_max_scaled_residual <= self.residual_tolerance - ) - if causal_candidate and causal_audit_due and not seeded_state_is_valid: - self._causal_audit_failure_count += 1 - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path("causalResidualAuditFailed") - causal_candidate = False - causal_audit_due = False - if seeded_state_is_valid: - diagnostics = AlgebraicSolveDiagnostics( - success=True, - message="Seeded pressure-flow state satisfies the residual tolerance.", - evaluations=0, - pressure_scale=pressure_scale, - flow_scale=flow_scale, - max_scaled_residual=seeded_max_scaled_residual, - max_raw_residual=max( - (abs(value) for value in seeded_values), - default=0.0, - ), - ) - if causal_candidate and causal_audit_due: - self._record_causal_audit(diagnostics) - self.last_diagnostics = diagnostics - return diagnostics - - unknown_scales = { - unknown.id: ( - max(abs(unknown.read()), 1.0) - if unknown.variable == "f" - else scales[unknown.variable] - ) - for unknown in self.unknowns - } - - def variable_scale(unknown: AlgebraicUnknown) -> float: - return unknown_scales[unknown.id] - - positive_pressures = [ - unknown.read() - for unknown in self._unknowns_by_variable["p"] - if unknown.read() > 0.0 - ] - fallback_pressure = ( - float(scale_context["fallback_pressure"]) - if scale_context is not None - else ( - sum(positive_pressures) / len(positive_pressures) - if positive_pressures - else pressure_scale - ) - ) - - # A causal contact retains its small relative penetration around the - # current absolute port coordinates. Keep that local coordinate during - # nonlinear fallback: the contact law remains responsive to optimizer - # increments, while a sub-ULP penetration is not lost by subtracting two - # large absolute displacements. - - x0 = np.asarray( - [ - ( - unknown.read() - if unknown.variable != "p" or unknown.read() > 0.0 - else fallback_pressure - ) - / variable_scale(unknown) - for unknown in self.unknowns - ], - dtype=float, - ) - lower = np.asarray( - [ - ( - PRESSURE_LOWER_BOUND_PA / pressure_scale - if unknown.variable == "p" - else -np.inf - ) - for unknown in self.unknowns - ] - ) - upper = np.full(len(self.unknowns), np.inf) - - def assign(values) -> None: - for unknown, value in zip(self.unknowns, values): - unknown.write(float(value) * variable_scale(unknown)) - - residual_evaluations = 0 - - def scaled_residuals(values): - nonlocal residual_evaluations - residual_evaluations += 1 - assign(values) - self._refresh_unilateral_contacts(contact_bindings) - equation_values = self._pressure_flow_equation_values() - return np.asarray( - [ - value / scale - for value, scale in zip(equation_values, equation_scales) - ], - dtype=float, - ) - - optimizer_arguments = { - "bounds": (lower, upper), - "x_scale": "jac", - "ftol": 1e-10, - "xtol": 1e-10, - "gtol": 1e-10, - "max_nfev": self.max_evaluations, - } - - def evaluate_result(result): - assign(result.x) - self._refresh_unilateral_contacts(contact_bindings) - current_equation_values = self._pressure_flow_equation_values() - current_scaled = [ - abs(value / scale) - for value, scale in zip( - current_equation_values, - equation_scales, - ) - ] - current_max_scaled_residual = max(current_scaled, default=0.0) - residuals_converged = ( - all(isfinite(value) for value in current_scaled) - and current_max_scaled_residual <= self.residual_tolerance - ) - optimizer_status_is_acceptable = ( - bool(result.success) or int(result.status) == 0 - ) - return ( - residuals_converged and optimizer_status_is_acceptable, - current_equation_values, - current_max_scaled_residual, - ) - - nonlinear_blocks: tuple[AlgebraicEquationBlock, ...] = () - nonlinear_block_unknown_count = 0 - block_fallback_used = False - block_fallback_reason: str | None = None - total_optimizer_evaluations = 0 - - if ( - self._equation_blocks_are_trusted - and self._jacobian_sparsity_is_trusted - ): - infeasible_unknown_indices = { - index - for index, (unknown, value) in enumerate(seeded_unknown_values) - if not isfinite(value) - or ( - unknown.variable == "p" - and value <= PRESSURE_LOWER_BOUND_PA - ) - } - nonlinear_blocks = tuple( - block - for block in self._equation_blocks - if any( - not isfinite(seeded_scaled[equation_index]) - or seeded_scaled[equation_index] > self.residual_tolerance - for equation_index in block.equation_indices - ) - or any( - unknown_index in infeasible_unknown_indices - for unknown_index in block.unknown_indices - ) - ) - nonlinear_block_unknown_count = sum( - len(block.unknown_indices) for block in nonlinear_blocks - ) - else: - block_fallback_reason = ( - self._equation_blocks_fallback_reason - or self._jacobian_sparsity_fallback_reason - or "untrustedAlgebraicStructure" - ) - - if contact_bindings: - # Contact activity is the stronger runtime reason even when its - # causal projection also makes the static graph appear rectangular. - block_fallback_reason = "activeCausalContact" - - block_subset: AlgebraicEquationSubset | None = None - if nonlinear_blocks: - if contact_bindings: - # Contact causalization mutates coordinates and component-local - # active-set caches during a residual call. The first version - # deliberately retains the proven global dense path whenever a - # contact is active; independent-contact pruning can be added - # only with an explicit side-effect dependency contract. - block_fallback_reason = "activeCausalContact" - elif nonlinear_block_unknown_count >= len(self.unknowns): - block_fallback_reason = "fullScopeNonlinearBlock" - else: - try: - block_subset = self._compile_equation_subset( - nonlinear_blocks - ) - except MemoryError: - raise - except Exception as exc: - block_fallback_reason = ( - f"blockCompilationFailed:{type(exc).__name__}" - ) - - if block_subset is not None: - subset_unknowns = tuple( - self.unknowns[index] - for index in block_subset.unknown_indices - ) - subset_x0 = x0[list(block_subset.unknown_indices)] - subset_lower = lower[list(block_subset.unknown_indices)] - subset_upper = upper[list(block_subset.unknown_indices)] - subset_equation_scales = tuple( - equation_scales[index] - for index in block_subset.equation_indices - ) - - def assign_subset(values) -> None: - for unknown, value in zip(subset_unknowns, values): - unknown.write(float(value) * variable_scale(unknown)) - - def scaled_subset_residuals(values): - nonlocal residual_evaluations - residual_evaluations += 1 - assign_subset(values) - return np.asarray( - [ - value / scale - for value, scale in zip( - block_subset.equation_values(), - subset_equation_scales, - ) - ], - dtype=float, - ) - - block_result = None - try: - block_result = least_squares( - scaled_subset_residuals, - subset_x0, - bounds=(subset_lower, subset_upper), - jac_sparsity=block_subset.jacobian_sparsity, - x_scale="jac", - ftol=1e-10, - xtol=1e-10, - gtol=1e-10, - max_nfev=self.max_evaluations, - ) - total_optimizer_evaluations += int(block_result.nfev) - assign_subset(block_result.x) - block_equation_values = self._pressure_flow_equation_values() - block_scaled = [ - abs(value / scale) - for value, scale in zip( - block_equation_values, - equation_scales, - ) - ] - block_unknowns_are_feasible = all( - isfinite(unknown.read()) - and ( - unknown.variable != "p" - or unknown.read() > PRESSURE_LOWER_BOUND_PA - ) - for unknown in self.unknowns - ) - block_success = ( - block_unknowns_are_feasible - and all(isfinite(value) for value in block_scaled) - and max(block_scaled, default=0.0) - <= self.residual_tolerance - and ( - bool(block_result.success) - or int(block_result.status) == 0 - ) - ) - if block_success: - diagnostics = AlgebraicSolveDiagnostics( - success=True, - message=str(block_result.message), - evaluations=total_optimizer_evaluations, - pressure_scale=pressure_scale, - flow_scale=flow_scale, - max_scaled_residual=max(block_scaled, default=0.0), - max_raw_residual=max( - (abs(value) for value in block_equation_values), - default=0.0, - ), - residual_evaluations=residual_evaluations, - jacobian_mode="blockSparse", - dense_fallback_used=False, - nonlinear_block_count=len(nonlinear_blocks), - nonlinear_block_unknown_count=( - nonlinear_block_unknown_count - ), - block_fallback_used=False, - block_fallback_reason=None, - ) - self.last_diagnostics = diagnostics - return diagnostics - block_fallback_reason = ( - "blockResidualNotConverged" - if block_result is not None - else "blockOptimizerFailed" - ) - except MemoryError: - assign(x0) - self._refresh_unilateral_contacts(contact_bindings) - raise - except Exception as exc: - block_fallback_reason = ( - f"blockSolveFailed:{type(exc).__name__}" - ) - except BaseException: - assign(x0) - self._refresh_unilateral_contacts(contact_bindings) - raise - - # A scoped solve is strictly an optimization. Its candidate must - # never seed the compatibility fallback, including candidates from - # blocks that converged before another block failed. - block_fallback_used = True - assign(x0) - self._refresh_unilateral_contacts(contact_bindings) - elif seeded_max_scaled_residual > self.residual_tolerance or not ( - seeded_unknowns_are_feasible - and all(isfinite(value) for value in seeded_scaled) - ): - block_fallback_used = True - - sparse_is_safe = ( - self._jacobian_sparsity_is_trusted - and not contact_bindings - and self._jacobian_sparsity.nnz > 0 - ) - dense_fallback_used = False - result = None - success = False - equation_values = seeded_values - max_scaled_residual = seeded_max_scaled_residual - if sparse_is_safe: - try: - sparse_result = least_squares( - scaled_residuals, - x0, - jac_sparsity=self._jacobian_sparsity, - **optimizer_arguments, - ) - except (ArithmeticError, RuntimeError, ValueError): - sparse_result = None - if sparse_result is not None: - total_optimizer_evaluations += int(sparse_result.nfev) - try: - ( - success, - equation_values, - max_scaled_residual, - ) = evaluate_result(sparse_result) - except (ArithmeticError, RuntimeError, ValueError): - success = False - result = sparse_result - - if not success: - # The sparse LSMR path is an optimization, not a new numerical - # contract. Restart the legacy dense solve from the exact - # original seed; a failed sparse candidate must not influence - # the fallback result through shared PortState objects. - dense_fallback_used = True - assign(x0) - self._refresh_unilateral_contacts(contact_bindings) - result = least_squares( - scaled_residuals, - x0, - **optimizer_arguments, - ) - total_optimizer_evaluations += int(result.nfev) - ( - success, - equation_values, - max_scaled_residual, - ) = evaluate_result(result) - jacobian_mode = ( - "sparseThenDense" if dense_fallback_used else "sparse" - ) - else: - result = least_squares( - scaled_residuals, - x0, - **optimizer_arguments, - ) - total_optimizer_evaluations = int(result.nfev) - ( - success, - equation_values, - max_scaled_residual, - ) = evaluate_result(result) - jacobian_mode = "dense" - - assert result is not None - diagnostics = AlgebraicSolveDiagnostics( - success=success, - message=str(result.message), - evaluations=total_optimizer_evaluations, - pressure_scale=pressure_scale, - flow_scale=flow_scale, - max_scaled_residual=max_scaled_residual, - max_raw_residual=max( - (abs(value) for value in equation_values), - default=0.0, - ), - residual_evaluations=residual_evaluations, - jacobian_mode=jacobian_mode, - dense_fallback_used=dense_fallback_used, - nonlinear_block_count=len(nonlinear_blocks), - nonlinear_block_unknown_count=nonlinear_block_unknown_count, - block_fallback_used=block_fallback_used, - block_fallback_reason=block_fallback_reason, - ) - self.last_diagnostics = diagnostics - if not success: - raise AlgebraicSolveError( - "Pressure-flow equations did not converge to the requested tolerance.", - diagnostics, - scope_kind=self.scope_kind, - scope_components=tuple(self.network.components), - ) - return diagnostics diff --git a/app/simulation/solvers/algebraic_blocks.py b/app/simulation/solvers/algebraic_blocks.py deleted file mode 100644 index ee3245b..0000000 --- a/app/simulation/solvers/algebraic_blocks.py +++ /dev/null @@ -1,1481 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping -from dataclasses import dataclass, replace -from math import isfinite - -from app.simulation.core.equations import EquationResidual -from app.simulation.solvers.algebraic import ( - PRESSURE_LOWER_BOUND_PA, - AlgebraicSolveDiagnostics, - AlgebraicUnknown, - CausalFlowKernelStage, - ExplicitFlowStage, - PressureFlowSolver, -) - - -@dataclass(frozen=True) -class StreamBlockSolveResult: - diagnostics: tuple[AlgebraicSolveDiagnostics, ...] - scopes: tuple[tuple[str, ...], ...] - used_global_fallback: bool - - -@dataclass(frozen=True) -class _BlockSolveAttempt: - diagnostics: AlgebraicSolveDiagnostics | None - optimizer_evaluations: int - residual_evaluations: int - failure_reason: str | None = None - - -@dataclass(frozen=True) -class _MutableAlgebraicStateSnapshot: - unknown_values: tuple[tuple[AlgebraicUnknown, float], ...] - causal_attributes: tuple[ - tuple[object, tuple[tuple[str, object], ...]], ... - ] - last_diagnostics: AlgebraicSolveDiagnostics | None - - @classmethod - def capture( - cls, - solver: PressureFlowSolver, - ) -> _MutableAlgebraicStateSnapshot: - causal_attribute_names = ( - "_causal_penetration", - "_causal_contact_force", - "_causal_port_1_x", - "_causal_port_2_x", - "_causal_port_1_v", - "_causal_port_2_v", - ) - - def component_causal_attributes( - component: object, - ) -> tuple[tuple[str, object], ...]: - names = dict.fromkeys( - [ - name - for name in getattr(component, "__dict__", {}) - if name.startswith("_causal_") - ] - + [ - name - for name in causal_attribute_names - if hasattr(component, name) - ] - ) - return tuple((name, getattr(component, name)) for name in names) - - return cls( - unknown_values=tuple( - (unknown, unknown.read()) for unknown in solver.unknowns - ), - causal_attributes=tuple( - ( - component, - component_causal_attributes(component), - ) - for component in solver._causal_contact_components - ), - last_diagnostics=solver.last_diagnostics, - ) - - def restore(self, solver: PressureFlowSolver) -> None: - for unknown, value in self.unknown_values: - unknown.write(value) - for component, attributes in self.causal_attributes: - original_names = {name for name, _value in attributes} - for name in tuple(getattr(component, "__dict__", {})): - if name.startswith("_causal_") and name not in original_names: - delattr(component, name) - for name, value in attributes: - setattr(component, name, value) - solver.last_diagnostics = self.last_diagnostics - - -@dataclass(frozen=True) -class _ScopedComponentEvaluation: - evaluate: Callable[[], tuple[float, ...]] - targets: tuple[tuple[int, int, str], ...] - - -@dataclass(frozen=True) -class _ScopedConnectionEvaluation: - target: int - evaluate: Callable[[], float] - equation_id: str - - -@dataclass(frozen=True) -class _EquationScaleSpec: - direct_scale: str | None - variable_names: tuple[str, ...] - - -def _scoped_equation_values( - equation_count: int, - component_evaluations: tuple[_ScopedComponentEvaluation, ...], - connection_evaluations: tuple[_ScopedConnectionEvaluation, ...], - zero_equation_ids: frozenset[str] = frozenset(), -) -> tuple[float, ...]: - values: list[float | None] = [None] * equation_count - for evaluation in component_evaluations: - if all( - equation_id in zero_equation_ids - for _target, _source, equation_id in evaluation.targets - ): - for target, _source, _equation_id in evaluation.targets: - values[target] = 0.0 - continue - component_values = evaluation.evaluate() - for target, source, equation_id in evaluation.targets: - if equation_id in zero_equation_ids: - values[target] = 0.0 - continue - if source >= len(component_values): - raise RuntimeError( - "Compiled algebraic equation disappeared at runtime: " - f"{equation_id}." - ) - values[target] = float(component_values[source]) - for evaluation in connection_evaluations: - values[evaluation.target] = ( - 0.0 - if evaluation.equation_id in zero_equation_ids - else float(evaluation.evaluate()) - ) - if any(value is None for value in values): - raise RuntimeError("Scoped algebraic evaluation returned no value.") - return tuple(float(value) for value in values) - - -@dataclass(frozen=True) -class _StreamAlgebraicBlock: - unknowns: tuple[AlgebraicUnknown, ...] - equations: tuple[EquationResidual, ...] - component_evaluations: tuple[_ScopedComponentEvaluation, ...] - connection_evaluations: tuple[_ScopedConnectionEvaluation, ...] - explicit_flow_plan: tuple[ExplicitFlowStage, ...] - scope_components: tuple[str, ...] - jacobian_entries: tuple[tuple[int, int], ...] - equation_scale_specs: tuple[_EquationScaleSpec, ...] - - def equation_values( - self, - zero_equation_ids: frozenset[str] = frozenset(), - ) -> tuple[float, ...]: - return _scoped_equation_values( - len(self.equations), - self.component_evaluations, - self.connection_evaluations, - zero_equation_ids, - ) - - -@dataclass(frozen=True) -class _SelectedEquationEvaluation: - equations: tuple[EquationResidual, ...] - component_evaluations: tuple[_ScopedComponentEvaluation, ...] - connection_evaluations: tuple[_ScopedConnectionEvaluation, ...] - block_ranges: tuple[tuple[int, int], ...] - equation_scale_specs: tuple[_EquationScaleSpec, ...] - scope_components: tuple[str, ...] - - def equation_values( - self, - zero_equation_ids: frozenset[str] = frozenset(), - ) -> tuple[float, ...]: - return _scoped_equation_values( - len(self.equations), - self.component_evaluations, - self.connection_evaluations, - zero_equation_ids, - ) - - -class StreamPressureBlockSolver: - """Re-close only equation blocks whose flow laws consume stream values. - - The primary pressure-flow solve remains global and performs all mechanical - contact and effort causalization. Stream propagation can only invalidate - equations owned by components that explicitly declare a stream dependency. - For trusted built-in components, the declared ``EquationResidual.variables`` - graph identifies the complete square blocks that must be revisited. Any - structural ambiguity keeps the legacy full-scope secondary solve. - """ - - def __init__( - self, - pressure_flow_solver: PressureFlowSolver, - sensitive_components: tuple[str, ...], - ) -> None: - self.pressure_flow_solver = pressure_flow_solver - self.sensitive_components = sensitive_components - self.fallback_reason: str | None = None - self.blocks = self._build_blocks() - self._selected_unknowns = tuple( - unknown for block in self.blocks for unknown in block.unknowns - ) - self._selected_unknown_ids = frozenset( - unknown.id for unknown in self._selected_unknowns - ) - self._selected_flow_unknowns = tuple( - unknown - for block in self.blocks - for unknown in block.unknowns - if unknown.variable == "m_flow" - ) - self._selected_explicit_flow_plan = tuple( - pressure_flow_solver._compile_explicit_flow_stage( - tuple( - assignment - for assignment in stage.assignments - if assignment.unknown.id in self._selected_unknown_ids - ) - ) - for stage in pressure_flow_solver._explicit_flow_plan - ) - secondary_coordinate = 0 - secondary_kernel_plan: list[CausalFlowKernelStage] = [] - for stage in self._selected_explicit_flow_plan: - coordinate_indices = tuple( - range( - secondary_coordinate, - secondary_coordinate + len(stage.assignments), - ) - ) - secondary_coordinate += len(stage.assignments) - secondary_kernel_plan.append( - CausalFlowKernelStage( - stage=stage, - coordinate_indices=coordinate_indices, - ) - ) - self._causal_secondary_flow_kernel_plan = tuple(secondary_kernel_plan) - self._causal_secondary_coordinate_values = [0.0] * secondary_coordinate - self._causal_v2_entry_values = [0.0] * len( - self._selected_flow_unknowns - ) - self._selected_equation_evaluation = ( - self._compile_selected_equation_evaluation() - if self.blocks - else None - ) - ( - self._entry_mutated_unknowns, - self._unselected_seed_restore_positions, - ) = self._compile_mutation_snapshot_plan() - ( - self._causal_fast_path_eligible, - self._causal_fast_path_fallback_reason, - self._causal_expected_flow_equation_ids, - self._causal_effort_entry_positions, - ) = self._build_causal_execution_plan() - self._causal_runtime_disabled_reason: str | None = None - self._causal_audit_required = True - self._causal_solves_since_audit = 0 - self._causal_fast_solve_count = 0 - self._causal_full_residual_audit_count = 0 - self._causal_audit_failure_count = 0 - self._causal_legacy_fallback_count = 0 - self._causal_v2_fast_solve_count = 0 - self._causal_v2_runtime_validation_failure_count = 0 - self._causal_coordinate_fast_solve_count = 0 - self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None - self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None - - @property - def available(self) -> bool: - return bool(self.blocks) and self.fallback_reason is None - - @property - def causal_fast_path_enabled(self) -> bool: - return ( - self._causal_fast_path_eligible - and self._causal_runtime_disabled_reason is None - and self.pressure_flow_solver.causal_fast_path_enabled - ) - - @property - def causal_executor_v2_enabled(self) -> bool: - return ( - self.causal_fast_path_enabled - and self.pressure_flow_solver._causal_executor_v2_environment_enabled - ) - - def request_causal_audit(self) -> None: - self._causal_audit_required = True - - def causal_execution_diagnostics(self) -> dict[str, object]: - parent = self.pressure_flow_solver.causal_execution_diagnostics() - disabled_reason = self._causal_runtime_disabled_reason - if not bool(parent["enabled"]): - disabled_reason = str( - parent["disabledReason"] or "parentCausalPathDisabled" - ) - elif not self._causal_fast_path_eligible: - disabled_reason = self._causal_fast_path_fallback_reason - verified = self._causal_last_verified_diagnostics - return { - "eligible": self._causal_fast_path_eligible, - "enabled": self.causal_fast_path_enabled, - "fallbackReason": self._causal_fast_path_fallback_reason, - "disabledReason": disabled_reason, - "fastSolveCount": self._causal_fast_solve_count, - "fullResidualAuditCount": self._causal_full_residual_audit_count, - "auditFailureCount": self._causal_audit_failure_count, - "legacyFallbackCount": self._causal_legacy_fallback_count, - "auditInterval": self.pressure_flow_solver._causal_audit_interval, - "solvesSinceAudit": self._causal_solves_since_audit, - "lastVerifiedMaxScaledResidual": ( - verified.max_scaled_residual if verified is not None else None - ), - "executorV2Configured": ( - self.pressure_flow_solver._causal_executor_v2_environment_enabled - ), - "executorV2Enabled": self.causal_executor_v2_enabled, - "executorV2FastSolveCount": self._causal_v2_fast_solve_count, - "executorV2RuntimeValidationFailureCount": ( - self._causal_v2_runtime_validation_failure_count - ), - "coordinateKernelConfigured": ( - self.pressure_flow_solver._causal_coordinate_kernel_environment_enabled - ), - "coordinateKernelEnabled": ( - self.causal_executor_v2_enabled - and self.pressure_flow_solver.causal_coordinate_kernel_enabled - ), - "coordinateKernelFastSolveCount": ( - self._causal_coordinate_fast_solve_count - ), - "compiledEffortUnknownCount": len( - self._causal_effort_entry_positions - ), - "compiledFlowAssignmentCount": len( - self._causal_expected_flow_equation_ids - ), - "compiledAssignmentCount": ( - len(self._causal_effort_entry_positions) - + len(self._causal_expected_flow_equation_ids) - ), - } - - def _disable_causal_fast_path(self, reason: str) -> None: - if self._causal_runtime_disabled_reason is None: - self._causal_runtime_disabled_reason = reason - self._causal_audit_required = True - - def _build_causal_execution_plan( - self, - ) -> tuple[ - bool, - str | None, - frozenset[str], - tuple[tuple[AlgebraicUnknown, int], ...], - ]: - def failed(reason: str): - return False, reason, frozenset(), () - - solver = self.pressure_flow_solver - if not self.available: - return failed(self.fallback_reason or "streamBlockUnavailable") - if not solver.causal_fast_path_eligible: - parent_reason = solver.causal_execution_diagnostics()["fallbackReason"] - return failed(str(parent_reason or "parentCausalPathIneligible")) - if solver._closed_resistance_pressure_plan: - return failed("specialClosedResistancePressureSeed") - if solver._resistance_pnl0001_series_plan: - return failed("specialSeriesPressureSeed") - selected = self._selected_equation_evaluation - if selected is None: - return failed("missingSelectedEquationEvaluation") - if any( - unknown.variable not in {"p", "m_flow"} - for unknown in self._selected_unknowns - ): - return failed("unsupportedSelectedUnknown") - - assignments = tuple( - assignment - for stage in self._selected_explicit_flow_plan - for assignment in stage.assignments - ) - assignment_unknown_ids = tuple( - assignment.unknown.id for assignment in assignments - ) - selected_flow_unknown_ids = frozenset( - unknown.id for unknown in self._selected_flow_unknowns - ) - if ( - frozenset(assignment_unknown_ids) != selected_flow_unknown_ids - or len(assignment_unknown_ids) != len(selected_flow_unknown_ids) - ): - return failed("incompleteSelectedFlowCoverage") - - assignment_equation_ids = tuple( - assignment.equation_id for assignment in assignments - ) - flow_equation_ids = frozenset( - equation.id for equation in selected.equations if equation.role == "flow" - ) - if ( - frozenset(assignment_equation_ids) != flow_equation_ids - or len(assignment_equation_ids) != len(flow_equation_ids) - ): - return failed("incompleteSelectedFlowEquationCoverage") - if any( - equation.role != "effort" - for equation in selected.equations - if equation.id not in flow_equation_ids - ): - return failed("selectedResidualIsNotEffortOnly") - - entry_position_by_id = { - unknown.id: position - for position, unknown in enumerate(self._entry_mutated_unknowns) - } - effort_positions: list[tuple[AlgebraicUnknown, int]] = [] - for unknown in self._selected_unknowns: - if unknown.variable != "p": - continue - position = entry_position_by_id.get(unknown.id) - if position is None: - return failed("missingEffortMutationSnapshot") - effort_positions.append((unknown, position)) - return ( - True, - None, - flow_equation_ids, - tuple(effort_positions), - ) - - def _causal_audit_is_due(self) -> bool: - return ( - self._causal_audit_required - or self._causal_last_verified_diagnostics is None - or self._causal_solves_since_audit - >= self.pressure_flow_solver._causal_audit_interval - ) - - def _record_causal_audit( - self, - diagnostics: AlgebraicSolveDiagnostics, - ) -> None: - self._causal_full_residual_audit_count += 1 - self._causal_solves_since_audit = 0 - self._causal_audit_required = False - self._causal_last_verified_diagnostics = diagnostics - self._causal_cached_fast_diagnostics = replace( - diagnostics, - message=( - "Compiled causal stream-pressure block completed; residuals " - "reuse the latest full audit." - ), - evaluations=0, - residual_evaluations=0, - dense_fallback_used=False, - nonlinear_block_count=0, - nonlinear_block_unknown_count=0, - block_fallback_used=False, - block_fallback_reason=None, - residual_verified_this_solve=False, - causal_fast_path_used=True, - ) - - def _causal_v2_fast_diagnostics(self) -> AlgebraicSolveDiagnostics: - cached = self._causal_cached_fast_diagnostics - if cached is None: - raise RuntimeError("Stream causal execution has no residual audit.") - return cached - - def _causal_fast_diagnostics( - self, - scale_context: Mapping[str, float], - ) -> AlgebraicSolveDiagnostics: - verified = self._causal_last_verified_diagnostics - if verified is None: - raise RuntimeError("Stream causal execution has no residual audit.") - return replace( - verified, - message=( - "Compiled causal stream-pressure block completed; residuals " - "reuse the latest full audit." - ), - evaluations=0, - pressure_scale=float(scale_context["p"]), - flow_scale=float(scale_context["m_flow"]), - residual_evaluations=0, - dense_fallback_used=False, - nonlinear_block_count=0, - nonlinear_block_unknown_count=0, - block_fallback_used=False, - block_fallback_reason=None, - residual_verified_this_solve=False, - causal_fast_path_used=True, - ) - - @staticmethod - def _equation_scale_spec(equation: EquationResidual) -> _EquationScaleSpec: - variable_names = tuple( - variable.rsplit(".", 1)[-1] for variable in equation.variables - ) - if equation.role == "flow": - return _EquationScaleSpec("m_flow", variable_names) - if equation.role == "effort" or "p" in variable_names: - return _EquationScaleSpec("p", variable_names) - return _EquationScaleSpec(None, variable_names) - - def _build_blocks(self) -> tuple[_StreamAlgebraicBlock, ...]: - solver = self.pressure_flow_solver - if not solver.jacobian_sparsity_is_trusted: - self.fallback_reason = ( - solver.jacobian_sparsity_fallback_reason - or "untrustedAlgebraicStructure" - ) - return () - unknowns = solver.unknowns - equations = solver.equation_templates - unknown_index_by_id = { - unknown.id: index for index, unknown in enumerate(unknowns) - } - equation_unknowns: list[tuple[int, ...]] = [] - equations_by_unknown: list[list[int]] = [[] for _unknown in unknowns] - for equation_index, equation in enumerate(equations): - dependencies = tuple( - dict.fromkeys( - unknown_index_by_id[variable] - for variable in equation.variables - if variable in unknown_index_by_id - ) - ) - if not dependencies: - self.fallback_reason = "equationWithoutDeclaredUnknown" - return () - equation_unknowns.append(dependencies) - for unknown_index in dependencies: - equations_by_unknown[unknown_index].append(equation_index) - - if any(not attached for attached in equations_by_unknown): - self.fallback_reason = "unknownWithoutDeclaredEquation" - return () - - raw_blocks: list[tuple[tuple[int, ...], tuple[int, ...]]] = [] - visited_unknowns: set[int] = set() - visited_equations: set[int] = set() - for root_unknown in range(len(unknowns)): - if root_unknown in visited_unknowns: - continue - block_unknowns: set[int] = set() - block_equations: set[int] = set() - pending_unknowns = [root_unknown] - while pending_unknowns: - unknown_index = pending_unknowns.pop() - if unknown_index in visited_unknowns: - continue - visited_unknowns.add(unknown_index) - block_unknowns.add(unknown_index) - for equation_index in equations_by_unknown[unknown_index]: - if equation_index not in visited_equations: - visited_equations.add(equation_index) - block_equations.add(equation_index) - for dependency in equation_unknowns[equation_index]: - if dependency not in visited_unknowns: - pending_unknowns.append(dependency) - ordered_unknowns = tuple(sorted(block_unknowns)) - ordered_equations = tuple(sorted(block_equations)) - if len(ordered_unknowns) != len(ordered_equations): - self.fallback_reason = "nonSquareEquationBlock" - return () - raw_blocks.append((ordered_unknowns, ordered_equations)) - - if len(visited_equations) != len(equations): - self.fallback_reason = "unreachableEquationBlock" - return () - - sensitive = frozenset(self.sensitive_components) - if sensitive - set(solver.network.components): - self.fallback_reason = "unknownSensitiveComponent" - return () - component_equation_owners = { - equation.owner_id - for equation in equations - if equation.owner == "component" - } - if sensitive - component_equation_owners: - self.fallback_reason = "sensitiveComponentHasNoEquationBlock" - return () - selected = [ - block - for block in raw_blocks - if any( - equations[equation_index].owner == "component" - and equations[equation_index].owner_id in sensitive - for equation_index in block[1] - ) - ] - if not selected: - self.fallback_reason = "sensitiveComponentHasNoEquationBlock" - return () - - for unknown_indices, equation_indices in selected: - if { - unknowns[unknown_index].variable - for unknown_index in unknown_indices - } - {"p", "m_flow"}: - self.fallback_reason = "streamBlockContainsNonPneumaticUnknown" - return () - component_names = { - unknowns[unknown_index].component - for unknown_index in unknown_indices - } | { - equations[equation_index].owner_id - for equation_index in equation_indices - if equations[equation_index].owner == "component" - } - if component_names - set(solver.network.components): - self.fallback_reason = "unknownEquationOwner" - return () - if any( - not type(solver.network.components[name]).__module__.startswith( - "app.simulation.components." - ) - for name in component_names - ): - self.fallback_reason = "untrustedCustomComponent" - return () - - equation_locations: list[tuple[str, object, int]] = [] - for component_plan in solver._component_equation_plan: - equation_locations.extend( - ("component", component_plan, local_index) - for local_index, _template in enumerate(component_plan.templates) - ) - equation_locations.extend( - ("connection", connection_plan, 0) - for connection_plan in solver._connection_equation_plan - ) - if len(equation_locations) != len(equations): - self.fallback_reason = "equationEvaluationPlanMismatch" - return () - - compiled: list[_StreamAlgebraicBlock] = [] - for unknown_indices, equation_indices in selected: - block_unknowns = tuple(unknowns[index] for index in unknown_indices) - block_equations = tuple(equations[index] for index in equation_indices) - block_unknown_ids = frozenset(unknown.id for unknown in block_unknowns) - component_targets: dict[int, list[tuple[int, int, str]]] = {} - component_plans: dict[int, object] = {} - connection_evaluations: list[_ScopedConnectionEvaluation] = [] - for target, global_equation_index in enumerate(equation_indices): - kind, evaluation_plan, source = equation_locations[ - global_equation_index - ] - if kind == "component": - key = id(evaluation_plan) - component_plans[key] = evaluation_plan - component_targets.setdefault(key, []).append( - ( - target, - source, - equations[global_equation_index].id, - ) - ) - else: - connection_evaluations.append( - _ScopedConnectionEvaluation( - target=target, - evaluate=evaluation_plan.evaluate, - equation_id=equations[global_equation_index].id, - ) - ) - component_evaluations = tuple( - _ScopedComponentEvaluation( - evaluate=component_plans[key].evaluate, - targets=tuple(targets), - ) - for key, targets in component_targets.items() - ) - explicit_flow_plan = tuple( - solver._compile_explicit_flow_stage( - tuple( - assignment - for assignment in stage.assignments - if assignment.unknown.id in block_unknown_ids - ) - ) - for stage in solver._explicit_flow_plan - ) - local_unknown_index = { - unknown.id: index for index, unknown in enumerate(block_unknowns) - } - jacobian_entries = tuple( - (row, local_unknown_index[variable]) - for row, equation in enumerate(block_equations) - for variable in dict.fromkeys(equation.variables) - if variable in local_unknown_index - ) - scope_components = tuple( - dict.fromkeys( - [unknown.component for unknown in block_unknowns] - + [ - equation.owner_id - for equation in block_equations - if equation.owner == "component" - ] - ) - ) - compiled.append( - _StreamAlgebraicBlock( - unknowns=block_unknowns, - equations=block_equations, - component_evaluations=component_evaluations, - connection_evaluations=tuple(connection_evaluations), - explicit_flow_plan=explicit_flow_plan, - scope_components=scope_components, - jacobian_entries=jacobian_entries, - equation_scale_specs=tuple( - self._equation_scale_spec(equation) - for equation in block_equations - ), - ) - ) - return tuple(compiled) - - def _compile_selected_equation_evaluation( - self, - ) -> _SelectedEquationEvaluation: - equations: list[EquationResidual] = [] - equation_scale_specs: list[_EquationScaleSpec] = [] - component_targets: dict[int, list[tuple[int, int, str]]] = {} - component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {} - connection_evaluations: list[_ScopedConnectionEvaluation] = [] - block_ranges: list[tuple[int, int]] = [] - scope_components: list[str] = [] - offset = 0 - for block in self.blocks: - start = offset - equations.extend(block.equations) - equation_scale_specs.extend(block.equation_scale_specs) - scope_components.extend(block.scope_components) - for evaluation in block.component_evaluations: - key = id(evaluation.evaluate) - component_evaluators[key] = evaluation.evaluate - component_targets.setdefault(key, []).extend( - (offset + target, source, equation_id) - for target, source, equation_id in evaluation.targets - ) - connection_evaluations.extend( - _ScopedConnectionEvaluation( - target=offset + evaluation.target, - evaluate=evaluation.evaluate, - equation_id=evaluation.equation_id, - ) - for evaluation in block.connection_evaluations - ) - offset += len(block.equations) - block_ranges.append((start, offset)) - return _SelectedEquationEvaluation( - equations=tuple(equations), - component_evaluations=tuple( - _ScopedComponentEvaluation( - evaluate=component_evaluators[key], - targets=tuple(targets), - ) - for key, targets in component_targets.items() - ), - connection_evaluations=tuple(connection_evaluations), - block_ranges=tuple(block_ranges), - equation_scale_specs=tuple(equation_scale_specs), - scope_components=tuple(dict.fromkeys(scope_components)), - ) - - def _compile_mutation_snapshot_plan( - self, - ) -> tuple[ - tuple[AlgebraicUnknown, ...], - tuple[tuple[AlgebraicUnknown, int], ...], - ]: - if not self.blocks: - return (), () - solver = self.pressure_flow_solver - unknowns_by_id = {unknown.id: unknown for unknown in solver.unknowns} - special_seed_target_ids: list[str] = [] - for binding in solver._closed_resistance_pressure_plan: - special_seed_target_ids.extend( - ( - f"{binding.component.name}.{binding.port_name}.p", - f"{binding.neighbor.name}.{binding.neighbor_port}.p", - ) - ) - for binding in solver._resistance_pnl0001_series_plan: - special_seed_target_ids.extend( - ( - f"{binding.resistance.name}.{binding.resistance_port}.p", - f"{binding.pipe.name}.{binding.pipe_port}.p", - ) - ) - - entry_mutated: list[AlgebraicUnknown] = [] - seen_ids: set[str] = set() - - def append_unknown(unknown: AlgebraicUnknown | None) -> None: - if unknown is None or unknown.id in seen_ids: - return - seen_ids.add(unknown.id) - entry_mutated.append(unknown) - - for block in self.blocks: - for unknown in block.unknowns: - append_unknown(unknown) - for target_id in special_seed_target_ids: - append_unknown(unknowns_by_id.get(target_id)) - - positions = { - unknown.id: index for index, unknown in enumerate(entry_mutated) - } - unselected_seed_restore_positions = tuple( - (unknowns_by_id[target_id], positions[target_id]) - for target_id in dict.fromkeys(special_seed_target_ids) - if target_id in unknowns_by_id - and target_id not in self._selected_unknown_ids - ) - return tuple(entry_mutated), unselected_seed_restore_positions - - def _seed_selected_blocks( - self, - entry_values: tuple[float, ...], - ) -> frozenset[str]: - solver = self.pressure_flow_solver - seeded_equation_ids: set[str] = set() - try: - # Reuse the global special seed plans because they encode catalog - # behavior such as PNOR/PNL0001 series pressure initialization. - # Shared PortState objects make those helpers capable of touching - # other equation blocks, so their exact unselected pressure targets - # are restored before block residuals are evaluated. Secondary - # closure deliberately mirrors ``solver.solve(effort_variables=())``: - # the primary global solve has already propagated equal pressures. - solver._seed_closed_resistance_pressures() - solver._seed_resistance_pnl0001_series_pressures() - for unknown in self._selected_flow_unknowns: - unknown.write(0.0) - for stage in self._selected_explicit_flow_plan: - values = solver._evaluate_explicit_flow_stage(stage) - for assignment, target_value in zip(stage.assignments, values): - if isfinite(target_value): - assignment.unknown.write(target_value) - seeded_equation_ids.add(assignment.equation_id) - finally: - for unknown, position in self._unselected_seed_restore_positions: - unknown.write(entry_values[position]) - return frozenset(seeded_equation_ids) - - def _execute_compiled_secondary_flow_plan(self) -> str | None: - """Execute selected flow assignments without equation-id sets.""" - - if self.pressure_flow_solver.causal_coordinate_kernel_enabled: - failure = ( - self.pressure_flow_solver._execute_causal_coordinate_flow_stages( - self._causal_secondary_flow_kernel_plan, - self._causal_secondary_coordinate_values, - ) - ) - if failure == "causalFlowAssignmentCountMismatch": - return "causalSecondaryFlowAssignmentCountMismatch" - if failure == "nonFiniteCausalFlowAssignment": - return "nonFiniteCausalSecondaryFlowAssignment" - if failure and failure.startswith("causalFlowEvaluationFailed:"): - return "causalSecondaryFlowEvaluationFailed:" + failure.rsplit( - ":", 1 - )[-1] - return failure - - for unknown in self._selected_flow_unknowns: - unknown.write(0.0) - for stage in self._selected_explicit_flow_plan: - try: - values = self.pressure_flow_solver._evaluate_explicit_flow_stage( - stage - ) - except MemoryError: - raise - except (ArithmeticError, RuntimeError, ValueError) as exc: - return f"causalSecondaryFlowEvaluationFailed:{type(exc).__name__}" - if len(values) != len(stage.assignments): - return "causalSecondaryFlowAssignmentCountMismatch" - for assignment, target_value in zip(stage.assignments, values): - if not isfinite(target_value): - return "nonFiniteCausalSecondaryFlowAssignment" - assignment.unknown.write(target_value) - return None - - @staticmethod - def _equation_scales_from_specs( - specs: tuple[_EquationScaleSpec, ...], - scales: Mapping[str, float], - ) -> tuple[float, ...]: - result: list[float] = [] - for spec in specs: - if spec.direct_scale is not None: - result.append(float(scales[spec.direct_scale])) - else: - result.append( - max( - [ - float(scales.get(name, 1.0)) - for name in spec.variable_names - ] - + [1.0] - ) - ) - return tuple(result) - - @classmethod - def _equation_scales( - cls, - block: _StreamAlgebraicBlock, - scales: Mapping[str, float], - ) -> tuple[float, ...]: - return cls._equation_scales_from_specs( - block.equation_scale_specs, - scales, - ) - - def _seeded_diagnostics( - self, - *, - unknowns: tuple[AlgebraicUnknown, ...], - equation_values: tuple[float, ...], - equation_scales: tuple[float, ...], - scale_context: Mapping[str, float], - message: str, - ) -> AlgebraicSolveDiagnostics | None: - scaled = tuple( - abs(value / scale) - for value, scale in zip(equation_values, equation_scales) - ) - feasible = all( - isfinite(unknown.read()) - and ( - unknown.variable != "p" - or unknown.read() > PRESSURE_LOWER_BOUND_PA - ) - for unknown in unknowns - ) - max_scaled = max(scaled, default=0.0) - if ( - not feasible - or not all(isfinite(value) for value in scaled) - or max_scaled > self.pressure_flow_solver.residual_tolerance - ): - return None - return AlgebraicSolveDiagnostics( - success=True, - message=message, - evaluations=0, - pressure_scale=float(scale_context["p"]), - flow_scale=float(scale_context["m_flow"]), - max_scaled_residual=max_scaled, - max_raw_residual=max( - (abs(value) for value in equation_values), - default=0.0, - ), - ) - - def _solve_block( - self, - block: _StreamAlgebraicBlock, - scale_context: Mapping[str, float], - *, - seeded_values: tuple[float, ...] | None = None, - equation_scales: tuple[float, ...] | None = None, - ) -> _BlockSolveAttempt: - import numpy as np - from scipy.optimize import least_squares - from scipy.sparse import csr_matrix - - if equation_scales is None: - equation_scales = self._equation_scales(block, scale_context) - if seeded_values is None: - seeded_values = block.equation_values() - solver = self.pressure_flow_solver - seeded_diagnostics = self._seeded_diagnostics( - unknowns=block.unknowns, - equation_values=seeded_values, - equation_scales=equation_scales, - scale_context=scale_context, - message=( - "Seeded stream-sensitive algebraic block satisfies the " - "residual tolerance." - ), - ) - if seeded_diagnostics is not None: - return _BlockSolveAttempt( - diagnostics=seeded_diagnostics, - optimizer_evaluations=0, - residual_evaluations=0, - ) - - unknown_scales = tuple( - float(scale_context[unknown.variable]) for unknown in block.unknowns - ) - fallback_pressure = float(scale_context["fallback_pressure"]) - x0 = np.asarray( - [ - ( - unknown.read() - if unknown.variable != "p" or unknown.read() > 0.0 - else fallback_pressure - ) - / scale - for unknown, scale in zip(block.unknowns, unknown_scales) - ], - dtype=float, - ) - lower = np.asarray( - [ - ( - PRESSURE_LOWER_BOUND_PA / float(scale_context["p"]) - if unknown.variable == "p" - else -np.inf - ) - for unknown in block.unknowns - ] - ) - upper = np.full(len(block.unknowns), np.inf) - - def assign(values) -> None: - for unknown, value, scale in zip( - block.unknowns, - values, - unknown_scales, - ): - unknown.write(float(value) * scale) - - residual_evaluations = 0 - - def scaled_residuals(values): - nonlocal residual_evaluations - residual_evaluations += 1 - assign(values) - return np.asarray( - [ - value / scale - for value, scale in zip( - block.equation_values(), - equation_scales, - ) - ], - dtype=float, - ) - - rows = [row for row, _column in block.jacobian_entries] - columns = [column for _row, column in block.jacobian_entries] - jacobian_sparsity = csr_matrix( - ( - np.ones(len(rows), dtype=bool), - (rows, columns), - ), - shape=(len(block.equations), len(block.unknowns)), - ) - if ( - any(jacobian_sparsity.getnnz(axis=1) == 0) - or any(jacobian_sparsity.getnnz(axis=0) == 0) - ): - return _BlockSolveAttempt( - diagnostics=None, - optimizer_evaluations=0, - residual_evaluations=0, - failure_reason="invalidBlockJacobianSparsity", - ) - result = None - try: - result = least_squares( - scaled_residuals, - x0, - bounds=(lower, upper), - jac_sparsity=jacobian_sparsity, - x_scale="jac", - ftol=1.0e-10, - xtol=1.0e-10, - gtol=1.0e-10, - max_nfev=solver.max_evaluations, - ) - assign(result.x) - equation_values = block.equation_values() - except MemoryError: - assign(x0) - raise - except Exception as exc: - assign(x0) - return _BlockSolveAttempt( - diagnostics=None, - optimizer_evaluations=( - int(result.nfev) if result is not None else 0 - ), - residual_evaluations=residual_evaluations, - failure_reason=f"blockSolveFailed:{type(exc).__name__}", - ) - except BaseException: - assign(x0) - raise - scaled = tuple( - abs(value / scale) - for value, scale in zip(equation_values, equation_scales) - ) - max_scaled = max(scaled, default=0.0) - success = ( - all(isfinite(value) for value in scaled) - and max_scaled <= solver.residual_tolerance - and (bool(result.success) or int(result.status) == 0) - ) - if not success: - assign(x0) - return _BlockSolveAttempt( - diagnostics=None, - optimizer_evaluations=int(result.nfev), - residual_evaluations=residual_evaluations, - failure_reason="blockResidualNotConverged", - ) - diagnostics = AlgebraicSolveDiagnostics( - success=True, - message=str(result.message), - evaluations=int(result.nfev), - pressure_scale=float(scale_context["p"]), - flow_scale=float(scale_context["m_flow"]), - max_scaled_residual=max_scaled, - max_raw_residual=max( - (abs(value) for value in equation_values), - default=0.0, - ), - residual_evaluations=residual_evaluations, - jacobian_mode="sparse", - dense_fallback_used=False, - nonlinear_block_count=1, - nonlinear_block_unknown_count=len(block.unknowns), - ) - return _BlockSolveAttempt( - diagnostics=diagnostics, - optimizer_evaluations=int(result.nfev), - residual_evaluations=residual_evaluations, - ) - - @staticmethod - def _aggregate_local_diagnostics( - diagnostics: tuple[AlgebraicSolveDiagnostics, ...], - *, - nonlinear_block_unknown_count: int, - ) -> AlgebraicSolveDiagnostics: - if len(diagnostics) == 1: - return diagnostics[0] - nonlinear = tuple( - item for item in diagnostics if item.jacobian_mode != "seeded" - ) - representative = diagnostics[-1] - return replace( - representative, - message=( - f"{len(diagnostics)} stream-sensitive algebraic blocks " - "satisfied the residual tolerance." - ), - evaluations=sum(item.evaluations for item in diagnostics), - max_scaled_residual=max( - item.max_scaled_residual for item in diagnostics - ), - max_raw_residual=max(item.max_raw_residual for item in diagnostics), - residual_evaluations=sum( - item.residual_evaluations for item in diagnostics - ), - jacobian_mode="blockSparse" if nonlinear else "seeded", - dense_fallback_used=any( - item.dense_fallback_used for item in diagnostics - ), - nonlinear_block_count=len(nonlinear), - nonlinear_block_unknown_count=( - nonlinear_block_unknown_count if nonlinear else 0 - ), - block_fallback_used=False, - block_fallback_reason=None, - ) - - def solve( - self, - *, - scale_context: Mapping[str, float] | None = None, - ) -> StreamBlockSolveResult: - solver = self.pressure_flow_solver - causal_candidate = self.causal_fast_path_enabled - causal_audit_due = ( - self._causal_audit_is_due() if causal_candidate else False - ) - causal_v2_candidate = ( - causal_candidate - and solver._causal_executor_v2_environment_enabled - and not causal_audit_due - ) - if causal_v2_candidate: - # The secondary causal proof rejects every special pressure seed, - # so this executor mutates selected flow coordinates only. Keep - # the minimal transactional snapshot for the rare fallback path. - v2_entry_values = self._causal_v2_entry_values - for position, unknown in enumerate(self._selected_flow_unknowns): - v2_entry_values[position] = unknown.state.m_flow - - def restore_v2_entry_mutations() -> None: - for unknown, value in zip( - self._selected_flow_unknowns, - v2_entry_values, - ): - unknown.state.m_flow = value - - try: - v2_failure_reason = ( - self._execute_compiled_secondary_flow_plan() - ) - except BaseException: - restore_v2_entry_mutations() - raise - if v2_failure_reason is None: - diagnostics = self._causal_v2_fast_diagnostics() - self._causal_fast_solve_count += 1 - self._causal_v2_fast_solve_count += 1 - if solver.causal_coordinate_kernel_enabled: - self._causal_coordinate_fast_solve_count += 1 - self._causal_solves_since_audit += 1 - selected = self._selected_equation_evaluation - assert selected is not None - return StreamBlockSolveResult( - diagnostics=(diagnostics,), - scopes=(selected.scope_components,), - used_global_fallback=False, - ) - restore_v2_entry_mutations() - self._causal_v2_runtime_validation_failure_count += 1 - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path(v2_failure_reason) - causal_candidate = False - causal_audit_due = False - - # Keep scale construction and the full mutation snapshot off the v2 - # success path. Callers may still precompute a shared scale mapping; - # avoiding that producer requires a later Generic-system API change. - context = dict(scale_context or solver.scale_context()) - entry_values = tuple( - unknown.read() for unknown in self._entry_mutated_unknowns - ) - - def restore_entry_mutations() -> None: - for unknown, value in zip( - self._entry_mutated_unknowns, - entry_values, - ): - unknown.write(value) - - def global_fallback( - *, - reason: str | None, - local_optimizer_evaluations: int = 0, - local_residual_evaluations: int = 0, - local_attempted: bool, - ) -> StreamBlockSolveResult: - restore_entry_mutations() - fallback_snapshot = _MutableAlgebraicStateSnapshot.capture(solver) - try: - fallback = solver.solve( - effort_variables=(), - scale_context=context, - ) - except BaseException: - fallback_snapshot.restore(solver) - raise - fallback_reason = reason or fallback.block_fallback_reason - if ( - reason - and fallback.block_fallback_reason - and reason != fallback.block_fallback_reason - ): - fallback_reason = ( - f"{reason};global:{fallback.block_fallback_reason}" - ) - aggregate = replace( - fallback, - evaluations=( - local_optimizer_evaluations + fallback.evaluations - ), - residual_evaluations=( - local_residual_evaluations - + fallback.residual_evaluations - ), - block_fallback_used=( - local_attempted or fallback.block_fallback_used - ), - block_fallback_reason=fallback_reason, - ) - return StreamBlockSolveResult( - diagnostics=(aggregate,), - scopes=(tuple(solver.network.components),), - used_global_fallback=True, - ) - - if not self.available: - return global_fallback( - reason=self.fallback_reason, - local_attempted=False, - ) - - try: - seeded_equation_ids = ( - self._seed_selected_blocks(entry_values) or frozenset() - ) - except MemoryError: - restore_entry_mutations() - raise - except Exception as exc: - if causal_candidate: - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path( - f"causalSecondarySeedFailed:{type(exc).__name__}" - ) - return global_fallback( - reason=f"secondarySeedFailed:{type(exc).__name__}", - local_attempted=True, - ) - except BaseException: - restore_entry_mutations() - raise - - if causal_candidate: - causal_runtime_gate_passed = ( - seeded_equation_ids == self._causal_expected_flow_equation_ids - and all( - isfinite(unknown.read()) - for unknown in self._selected_flow_unknowns - ) - and all( - unknown.read() == entry_values[position] - for unknown, position in self._causal_effort_entry_positions - ) - ) - if not causal_runtime_gate_passed: - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path("causalSecondaryRuntimeGateFailed") - causal_candidate = False - causal_audit_due = False - elif not causal_audit_due: - diagnostics = self._causal_fast_diagnostics(context) - self._causal_fast_solve_count += 1 - self._causal_solves_since_audit += 1 - return StreamBlockSolveResult( - diagnostics=(diagnostics,), - scopes=( - self._selected_equation_evaluation.scope_components, - ), - used_global_fallback=False, - ) - - selected_evaluation = self._selected_equation_evaluation - assert selected_evaluation is not None - try: - selected_values = selected_evaluation.equation_values( - seeded_equation_ids - ) - selected_scales = self._equation_scales_from_specs( - selected_evaluation.equation_scale_specs, - context, - ) - union_diagnostics = self._seeded_diagnostics( - unknowns=self._selected_unknowns, - equation_values=selected_values, - equation_scales=selected_scales, - scale_context=context, - message=( - "Seeded stream-sensitive algebraic equation blocks satisfy " - "the residual tolerance." - ), - ) - except MemoryError: - restore_entry_mutations() - raise - except Exception as exc: - if causal_candidate: - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path( - "causalSecondaryResidualEvaluationFailed:" - f"{type(exc).__name__}" - ) - return global_fallback( - reason=( - "secondaryResidualEvaluationFailed:" - f"{type(exc).__name__}" - ), - local_attempted=True, - ) - except BaseException: - restore_entry_mutations() - raise - if union_diagnostics is not None: - if causal_candidate and causal_audit_due: - self._record_causal_audit(union_diagnostics) - return StreamBlockSolveResult( - diagnostics=(union_diagnostics,), - scopes=(selected_evaluation.scope_components,), - used_global_fallback=False, - ) - if causal_candidate and causal_audit_due: - self._causal_audit_failure_count += 1 - self._causal_legacy_fallback_count += 1 - self._disable_causal_fast_path("causalSecondaryResidualAuditFailed") - causal_candidate = False - - diagnostics: list[AlgebraicSolveDiagnostics] = [] - local_optimizer_evaluations = 0 - local_residual_evaluations = 0 - nonlinear_block_unknown_count = 0 - for block, (start, stop) in zip( - self.blocks, - selected_evaluation.block_ranges, - ): - try: - attempt = self._solve_block( - block, - context, - seeded_values=selected_values[start:stop], - equation_scales=selected_scales[start:stop], - ) - except MemoryError: - restore_entry_mutations() - raise - except Exception as exc: - return global_fallback( - reason=f"secondaryBlockSolveFailed:{type(exc).__name__}", - local_optimizer_evaluations=local_optimizer_evaluations, - local_residual_evaluations=local_residual_evaluations, - local_attempted=True, - ) - except BaseException: - restore_entry_mutations() - raise - local_optimizer_evaluations += attempt.optimizer_evaluations - local_residual_evaluations += attempt.residual_evaluations - if attempt.diagnostics is None: - return global_fallback( - reason=attempt.failure_reason or "secondaryBlockSolveFailed", - local_optimizer_evaluations=local_optimizer_evaluations, - local_residual_evaluations=local_residual_evaluations, - local_attempted=True, - ) - diagnostics.append(attempt.diagnostics) - if attempt.diagnostics.jacobian_mode != "seeded": - nonlinear_block_unknown_count += len(block.unknowns) - aggregate = self._aggregate_local_diagnostics( - tuple(diagnostics), - nonlinear_block_unknown_count=nonlinear_block_unknown_count, - ) - return StreamBlockSolveResult( - diagnostics=(aggregate,), - scopes=(selected_evaluation.scope_components,), - used_global_fallback=False, - ) diff --git a/app/simulation/solvers/causal_ir.py b/app/simulation/solvers/causal_ir.py deleted file mode 100644 index 731c854..0000000 --- a/app/simulation/solvers/causal_ir.py +++ /dev/null @@ -1,794 +0,0 @@ -"""Executable reference IR for compile-proven causal algebraic programs. - -The IR eliminates duplicate *logical* effort coordinates, but intentionally -keeps a compatibility scatter map to existing ``PortState`` objects. Stream -propagation, derivatives, and result collection still consume those objects; -this is a reference for a future flat backend, not physical slot deletion. -""" - -from __future__ import annotations - -from collections.abc import Callable, Iterable -from dataclasses import dataclass, replace -from enum import StrEnum -from hashlib import sha256 -import json -from math import isfinite -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - import numpy as np - - -CAUSAL_NUMERIC_IR_SCHEMA_VERSION = 1 -PRESSURE_LOWER_BOUND_PA = 0.0 - - -class CausalIROpcode(StrEnum): - EFFORT_BROADCAST = "effort_broadcast" - EFFORT_DIRECT_RESIDUAL = "effort_direct_residual" - EFFORT_COMPONENT_RESIDUAL = "effort_component_residual" - FLOW_DIRECT = "flow_direct" - FLOW_COMPONENT_RESIDUAL = "flow_component_residual" - - -@dataclass(frozen=True, slots=True) -class CausalIRCompatibilitySlot: - slot: int - id: str - variable: str - - -@dataclass(frozen=True, slots=True) -class CausalIRCanonicalSlot: - slot: int - id: str - variable: str - kind: str - - -@dataclass(frozen=True, slots=True) -class CausalIREffortOperation: - opcode: CausalIROpcode - variable: str - result_slot: int - anchor_compatibility_slot: int - scatter_compatibility_slots: tuple[int, ...] - equation_id: str - - -@dataclass(frozen=True, slots=True) -class CausalIREffortEvaluation: - opcode: CausalIROpcode - output_indices: tuple[int, ...] - equation_indices: tuple[int, ...] - equation_ids: tuple[str, ...] - evaluator_slot: int - - -@dataclass(frozen=True, slots=True) -class CausalIREffortStage: - variable: str - operations: tuple[CausalIREffortOperation, ...] - evaluations: tuple[CausalIREffortEvaluation, ...] - - -@dataclass(frozen=True, slots=True) -class CausalIRFlowOperation: - opcode: CausalIROpcode - output_indices: tuple[int, ...] - equation_indices: tuple[int, ...] - equation_ids: tuple[str, ...] - evaluator_slot: int - - -@dataclass(frozen=True, slots=True) -class CausalIRFlowStage: - target_slots: tuple[int, ...] - scatter_compatibility_slots: tuple[int, ...] - equation_ids: tuple[str, ...] - operations: tuple[CausalIRFlowOperation, ...] - - -@dataclass(frozen=True, slots=True) -class CausalIRProgram: - """Immutable callback-free structure used as the backend cache key.""" - - schema_version: int - canonical_slots: tuple[CausalIRCanonicalSlot, ...] - compatibility_slots: tuple[CausalIRCompatibilitySlot, ...] - reset_compatibility_slots: tuple[int, ...] - external_effort_compatibility_slots: tuple[int, ...] - effort_stages: tuple[CausalIREffortStage, ...] - flow_stages: tuple[CausalIRFlowStage, ...] - structural_signature: str - - @property - def assignment_count(self) -> int: - return len(self.canonical_slots) - - @property - def effort_group_count(self) -> int: - return sum(len(stage.operations) for stage in self.effort_stages) - - @property - def flow_assignment_count(self) -> int: - return sum(len(stage.target_slots) for stage in self.flow_stages) - - @property - def effort_scatter_count(self) -> int: - return sum( - len(operation.scatter_compatibility_slots) - for stage in self.effort_stages - for operation in stage.operations - ) - - @property - def eliminated_effort_replica_count(self) -> int: - return self.effort_scatter_count - self.effort_group_count - - @property - def maximum_effort_stage_width(self) -> int: - return max((len(stage.operations) for stage in self.effort_stages), default=0) - - @property - def maximum_flow_stage_width(self) -> int: - return max((len(stage.target_slots) for stage in self.flow_stages), default=0) - - def structural_dict(self) -> dict[str, object]: - return { - "schemaVersion": self.schema_version, - "canonicalSlots": [ - { - "slot": item.slot, - "id": item.id, - "variable": item.variable, - "kind": item.kind, - } - for item in self.canonical_slots - ], - "compatibilitySlots": [ - {"slot": item.slot, "id": item.id, "variable": item.variable} - for item in self.compatibility_slots - ], - "resetCompatibilitySlots": list(self.reset_compatibility_slots), - "externalEffortCompatibilitySlots": list( - self.external_effort_compatibility_slots - ), - "effortStages": [ - { - "variable": stage.variable, - "operations": [ - { - "opcode": operation.opcode.value, - "resultSlot": operation.result_slot, - "anchorCompatibilitySlot": ( - operation.anchor_compatibility_slot - ), - "scatterCompatibilitySlots": list( - operation.scatter_compatibility_slots - ), - "equationId": operation.equation_id, - } - for operation in stage.operations - ], - "evaluations": [ - { - "opcode": evaluation.opcode.value, - "outputIndices": list(evaluation.output_indices), - "equationIndices": list(evaluation.equation_indices), - "equationIds": list(evaluation.equation_ids), - "evaluatorSlot": evaluation.evaluator_slot, - } - for evaluation in stage.evaluations - ], - } - for stage in self.effort_stages - ], - "flowStages": [ - { - "targetSlots": list(stage.target_slots), - "scatterCompatibilitySlots": list( - stage.scatter_compatibility_slots - ), - "equationIds": list(stage.equation_ids), - "operations": [ - { - "opcode": operation.opcode.value, - "outputIndices": list(operation.output_indices), - "equationIndices": list(operation.equation_indices), - "equationIds": list(operation.equation_ids), - "evaluatorSlot": operation.evaluator_slot, - } - for operation in stage.operations - ], - } - for stage in self.flow_stages - ], - } - - def calculate_structural_signature(self) -> str: - payload = json.dumps( - self.structural_dict(), - ensure_ascii=True, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - return sha256(payload).hexdigest() - - -@dataclass(frozen=True, slots=True) -class CausalIRBindings: - readers: tuple[Callable[[], float], ...] - writers: tuple[Callable[[float], None], ...] - evaluators: tuple[Callable[[], object], ...] - - -@dataclass(slots=True) -class CausalIRWorkspace: - structural_signature: str - canonical_values: "np.ndarray[Any, Any]" - effort_residuals: "np.ndarray[Any, Any]" - effort_written: "np.ndarray[Any, Any]" - flow_values: "np.ndarray[Any, Any]" - flow_written: "np.ndarray[Any, Any]" - transaction_values: "np.ndarray[Any, Any]" - - -@dataclass(frozen=True, slots=True) -class CausalIRExecutionResult: - success: bool - fallback_reason: str | None - structural_signature: str - effort_assignment_count: int - flow_assignment_count: int - completed_effort_stage_count: int - completed_flow_stage_count: int - rolled_back: bool - - -StageObserver = Callable[ - [str, int, tuple[int, ...], tuple[float, ...]], - None, -] - - -@dataclass(frozen=True, slots=True) -class CausalNumericIR: - """Bound reference IR; its normal path performs no full snapshot.""" - - program: CausalIRProgram - bindings: CausalIRBindings - - def create_workspace(self) -> CausalIRWorkspace: - try: - import numpy as np - except ImportError as exc: # pragma: no cover - raise RuntimeError("The causal numeric reference IR requires NumPy.") from exc - return CausalIRWorkspace( - structural_signature=self.program.structural_signature, - canonical_values=np.empty( - max(len(self.program.canonical_slots), 1), dtype=np.float64 - ), - effort_residuals=np.empty( - max(self.program.maximum_effort_stage_width, 1), dtype=np.float64 - ), - effort_written=np.empty( - max(self.program.maximum_effort_stage_width, 1), dtype=np.bool_ - ), - flow_values=np.empty( - max(self.program.maximum_flow_stage_width, 1), dtype=np.float64 - ), - flow_written=np.empty( - max(self.program.maximum_flow_stage_width, 1), dtype=np.bool_ - ), - transaction_values=np.empty( - max(len(self.program.compatibility_slots), 1), dtype=np.float64 - ), - ) - - def execute( - self, - workspace: CausalIRWorkspace, - *, - effort_variables: tuple[str, ...] = ("p",), - transactional: bool = False, - stage_observer: StageObserver | None = None, - ) -> CausalIRExecutionResult: - """Interpret the IR; transactional snapshots are audit-only.""" - - program = self.program - bindings = self.bindings - signature = program.structural_signature - if workspace.structural_signature != signature: - raise ValueError("Causal IR workspace belongs to a different program.") - if len(bindings.readers) != len(program.compatibility_slots) or len( - bindings.writers - ) != len(program.compatibility_slots): - raise ValueError("Causal IR compatibility binding count is inconsistent.") - if any(variable not in {"p", "x", "v"} for variable in effort_variables): - return CausalIRExecutionResult( - False, "unsupportedEffortVariable", signature, 0, 0, 0, 0, False - ) - - snapshot_count = 0 - if transactional: - try: - for slot, reader in enumerate(bindings.readers): - workspace.transaction_values[slot] = float(reader()) - snapshot_count += 1 - except MemoryError: - raise - except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc: - return CausalIRExecutionResult( - False, - f"slotReadFailed:{type(exc).__name__}", - signature, - 0, - 0, - 0, - 0, - False, - ) - - effort_count = 0 - flow_count = 0 - completed_effort_stages = 0 - completed_flow_stages = 0 - - def failed(reason: str) -> CausalIRExecutionResult: - rolled_back = False - if transactional: - for slot in range(snapshot_count): - bindings.writers[slot](float(workspace.transaction_values[slot])) - rolled_back = True - return CausalIRExecutionResult( - False, - reason, - signature, - effort_count, - flow_count, - completed_effort_stages, - completed_flow_stages, - rolled_back, - ) - - selected_efforts = frozenset(effort_variables) - for stage_index, stage in enumerate(program.effort_stages): - if stage.variable not in selected_efforts: - continue - width = len(stage.operations) - workspace.effort_written[:width] = False - for evaluation in stage.evaluations: - try: - evaluated = bindings.evaluators[evaluation.evaluator_slot]() - if evaluation.opcode is CausalIROpcode.EFFORT_DIRECT_RESIDUAL: - output = evaluation.output_indices[0] - workspace.effort_residuals[output] = float(evaluated) - workspace.effort_written[output] = True - continue - if not hasattr(evaluated, "__len__"): - raise TypeError("component evaluator returned no sequence") - for output, equation in zip( - evaluation.output_indices, evaluation.equation_indices - ): - if equation >= len(evaluated): - raise IndexError("component equation disappeared") - workspace.effort_residuals[output] = float(evaluated[equation]) - workspace.effort_written[output] = True - except MemoryError: - raise - except Exception as exc: - return failed(f"effortEvaluationFailed:{type(exc).__name__}") - if any(not bool(workspace.effort_written[index]) for index in range(width)): - return failed("effortEvaluationCoverageMismatch") - for output, operation in enumerate(stage.operations): - try: - anchor = float(bindings.readers[operation.anchor_compatibility_slot]()) - target = anchor - float(workspace.effort_residuals[output]) - except MemoryError: - raise - except ( - ArithmeticError, - IndexError, - RuntimeError, - TypeError, - ValueError, - ) as exc: - return failed(f"effortAssignmentFailed:{type(exc).__name__}") - if not isfinite(target) or ( - stage.variable == "p" and target <= PRESSURE_LOWER_BOUND_PA - ): - return failed("nonFiniteOrInvalidEffortAssignment") - workspace.canonical_values[operation.result_slot] = target - for slot in operation.scatter_compatibility_slots: - bindings.writers[slot](target) - effort_count += 1 - completed_effort_stages += 1 - if stage_observer is not None: - try: - stage_observer( - f"effort:{stage.variable}", - stage_index, - tuple(item.result_slot for item in stage.operations), - tuple( - float(workspace.canonical_values[item.result_slot]) - for item in stage.operations - ), - ) - except MemoryError: - raise - except Exception as exc: - return failed(f"stageObserverFailed:{type(exc).__name__}") - - try: - external_finite = all( - isfinite(float(bindings.readers[slot]())) - for slot in program.external_effort_compatibility_slots - ) - except MemoryError: - raise - except (ArithmeticError, RuntimeError, TypeError, ValueError) as exc: - return failed(f"externalEffortReadFailed:{type(exc).__name__}") - if not external_finite: - return failed("nonFiniteExternalEffort") - - for slot in program.reset_compatibility_slots: - bindings.writers[slot](0.0) - for stage_index, stage in enumerate(program.flow_stages): - width = len(stage.target_slots) - workspace.flow_written[:width] = False - for operation in stage.operations: - try: - evaluated = bindings.evaluators[operation.evaluator_slot]() - if operation.opcode is CausalIROpcode.FLOW_DIRECT: - output = operation.output_indices[0] - workspace.flow_values[output] = float(evaluated) - workspace.flow_written[output] = True - continue - if not hasattr(evaluated, "__len__"): - raise TypeError("component evaluator returned no sequence") - for output, equation in zip( - operation.output_indices, operation.equation_indices - ): - if equation >= len(evaluated): - raise IndexError("component equation disappeared") - # Targets are zero before the stage; preserve -residual. - workspace.flow_values[output] = -float(evaluated[equation]) - workspace.flow_written[output] = True - except MemoryError: - raise - except ( - ArithmeticError, - IndexError, - RuntimeError, - TypeError, - ValueError, - ) as exc: - return failed(f"flowEvaluationFailed:{type(exc).__name__}") - if any(not bool(workspace.flow_written[index]) for index in range(width)): - return failed("flowAssignmentCoverageMismatch") - for output, (canonical, compatibility) in enumerate( - zip(stage.target_slots, stage.scatter_compatibility_slots) - ): - target = float(workspace.flow_values[output]) - if not isfinite(target): - return failed("nonFiniteFlowAssignment") - workspace.canonical_values[canonical] = target - bindings.writers[compatibility](target) - flow_count += 1 - completed_flow_stages += 1 - if stage_observer is not None: - try: - stage_observer( - "flow", - stage_index, - stage.target_slots, - tuple(float(workspace.flow_values[i]) for i in range(width)), - ) - except MemoryError: - raise - except Exception as exc: - return failed(f"stageObserverFailed:{type(exc).__name__}") - - return CausalIRExecutionResult( - True, - None, - signature, - effort_count, - flow_count, - completed_effort_stages, - completed_flow_stages, - False, - ) - - -@dataclass(frozen=True, slots=True) -class CausalIRCompilation: - ir: CausalNumericIR | None - fallback_reason: str | None - - @property - def supported(self) -> bool: - return self.ir is not None and self.fallback_reason is None - - -def _unsupported(reason: str) -> CausalIRCompilation: - return CausalIRCompilation(ir=None, fallback_reason=reason) - - -def _unique_slots(items: Iterable[int]) -> tuple[int, ...]: - return tuple(dict.fromkeys(int(item) for item in items)) - - -def _compile_effort_evaluations( - operations: tuple[CausalIREffortOperation, ...], - anchor_evaluators: tuple[Callable[[], float], ...], - direct_residuals: tuple[bool, ...], - component_locations: dict[ - str, tuple[object, Callable[[], tuple[float, ...]], int] - ], - evaluators: list[Callable[[], object]], -) -> tuple[CausalIREffortEvaluation, ...]: - grouped: dict[int, list[tuple[int, int, str]]] = {} - component_callbacks: dict[int, Callable[[], tuple[float, ...]]] = {} - direct: list[tuple[int, Callable[[], float], str]] = [] - for output, (operation, anchor_evaluate, direct_residual) in enumerate( - zip(operations, anchor_evaluators, direct_residuals) - ): - location = ( - None - if direct_residual - else component_locations.get(operation.equation_id) - ) - if location is None: - direct.append((output, anchor_evaluate, operation.equation_id)) - continue - owner, evaluate, equation = location - key = id(owner) - component_callbacks[key] = evaluate - grouped.setdefault(key, []).append((output, equation, operation.equation_id)) - - compiled: list[CausalIREffortEvaluation] = [] - for output, evaluate, equation_id in direct: - evaluator = len(evaluators) - evaluators.append(evaluate) - compiled.append( - CausalIREffortEvaluation( - CausalIROpcode.EFFORT_DIRECT_RESIDUAL, - (output,), - (), - (equation_id,), - evaluator, - ) - ) - for key, entries in grouped.items(): - evaluator = len(evaluators) - evaluators.append(component_callbacks[key]) - compiled.append( - CausalIREffortEvaluation( - CausalIROpcode.EFFORT_COMPONENT_RESIDUAL, - tuple(item[0] for item in entries), - tuple(item[1] for item in entries), - tuple(item[2] for item in entries), - evaluator, - ) - ) - return tuple(compiled) - - -def compile_causal_numeric_ir(solver: object) -> CausalIRCompilation: - """Lower a compile-proven global plan; unsupported plans fail closed.""" - - if not bool(getattr(solver, "_causal_fast_path_eligible", False)): - return _unsupported( - str( - getattr(solver, "_causal_fast_path_fallback_reason", None) - or "causalProofNotAvailable" - ) - ) - try: - unknowns = tuple(getattr(solver, "unknowns")) - effort_plan = getattr(solver, "_causal_effort_plan_by_variable") - flow_plan = tuple(getattr(solver, "_explicit_flow_plan")) - component_plan = tuple(getattr(solver, "_component_equation_plan")) - reset_unknowns = tuple( - getattr(solver, "_explicit_flow_unknowns_by_variables")[ - frozenset(("f", "m_flow")) - ] - ) - external_unknowns = tuple( - getattr(solver, "_causal_external_effort_unknowns") - ) - except (AttributeError, KeyError, TypeError): - return _unsupported("unsupportedCausalSolverContract") - - unknown_ids = tuple(str(item.id) for item in unknowns) - if len(set(unknown_ids)) != len(unknown_ids): - return _unsupported("duplicateAlgebraicUnknown") - compatibility_slot_by_id = { - unknown_id: slot for slot, unknown_id in enumerate(unknown_ids) - } - compatibility_slots = tuple( - CausalIRCompatibilitySlot(slot, unknown_id, str(unknown.variable)) - for slot, (unknown_id, unknown) in enumerate(zip(unknown_ids, unknowns)) - ) - readers = tuple(item.read for item in unknowns) - writers = tuple(item.write for item in unknowns) - evaluators: list[Callable[[], object]] = [] - canonical_slots: list[CausalIRCanonicalSlot] = [] - - component_locations: dict[ - str, tuple[object, Callable[[], tuple[float, ...]], int] - ] = {} - try: - for plan in component_plan: - for equation, template in enumerate(plan.templates): - component_locations[str(template.id)] = ( - plan.component, - plan.evaluate, - equation, - ) - except (AttributeError, TypeError): - return _unsupported("unsupportedComponentEvaluationContract") - - effort_stages: list[CausalIREffortStage] = [] - try: - for variable in ("p", "x", "v"): - operations: list[CausalIREffortOperation] = [] - anchors: list[Callable[[], float]] = [] - direct_residuals: list[bool] = [] - for assignment in effort_plan[variable]: - result = len(canonical_slots) - equation_id = str(assignment.anchor.equation_id) - scatter = tuple( - compatibility_slot_by_id[item.id] - for item in assignment.members - ) - if not scatter or len(set(scatter)) != len(scatter): - return _unsupported("invalidEffortScatterSlots") - canonical_slots.append( - CausalIRCanonicalSlot( - result, - f"effort:{variable}:{equation_id}", - variable, - "effort_group", - ) - ) - operations.append( - CausalIREffortOperation( - CausalIROpcode.EFFORT_BROADCAST, - variable, - result, - compatibility_slot_by_id[assignment.anchor.unknown.id], - scatter, - equation_id, - ) - ) - causal_evaluate = getattr( - assignment.anchor, - "causal_evaluate", - None, - ) - anchors.append( - causal_evaluate - if causal_evaluate is not None - else assignment.anchor.evaluate - ) - direct_residuals.append(causal_evaluate is not None) - operation_tuple = tuple(operations) - effort_stages.append( - CausalIREffortStage( - variable, - operation_tuple, - _compile_effort_evaluations( - operation_tuple, - tuple(anchors), - tuple(direct_residuals), - component_locations, - evaluators, - ), - ) - ) - except (AttributeError, KeyError, TypeError): - return _unsupported("unsupportedEffortPlanContract") - - flow_stages: list[CausalIRFlowStage] = [] - try: - for stage in flow_plan: - scatter = tuple( - compatibility_slot_by_id[item.unknown.id] - for item in stage.assignments - ) - equation_ids = tuple(str(item.equation_id) for item in stage.assignments) - if len(set(scatter)) != len(scatter): - return _unsupported("duplicateFlowTargetInStage") - targets: list[int] = [] - for assignment in stage.assignments: - target = len(canonical_slots) - targets.append(target) - canonical_slots.append( - CausalIRCanonicalSlot( - target, - f"flow:{assignment.unknown.id}", - str(assignment.unknown.variable), - "flow_assignment", - ) - ) - covered: list[int] = [] - operations: list[CausalIRFlowOperation] = [] - for output, evaluate in stage.direct_evaluations: - output = int(output) - evaluator = len(evaluators) - evaluators.append(evaluate) - operations.append( - CausalIRFlowOperation( - CausalIROpcode.FLOW_DIRECT, - (output,), - (), - (equation_ids[output],), - evaluator, - ) - ) - covered.append(output) - for evaluation in stage.component_evaluations: - evaluator = len(evaluators) - evaluators.append(evaluation.evaluate) - outputs = tuple(int(item) for item in evaluation.assignment_indices) - operations.append( - CausalIRFlowOperation( - CausalIROpcode.FLOW_COMPONENT_RESIDUAL, - outputs, - tuple(int(item) for item in evaluation.equation_indices), - tuple(str(item) for item in evaluation.equation_ids), - evaluator, - ) - ) - covered.extend(outputs) - if sorted(covered) != list(range(len(scatter))): - return _unsupported("flowStageEvaluationCoverageMismatch") - flow_stages.append( - CausalIRFlowStage( - tuple(targets), scatter, equation_ids, tuple(operations) - ) - ) - except (AttributeError, IndexError, KeyError, TypeError): - return _unsupported("unsupportedFlowPlanContract") - - try: - reset_slots = _unique_slots( - compatibility_slot_by_id[item.id] for item in reset_unknowns - ) - external_slots = _unique_slots( - compatibility_slot_by_id[item.id] for item in external_unknowns - ) - except (AttributeError, KeyError): - return _unsupported("unknownCausalBoundarySlot") - flow_scatter = tuple( - item for stage in flow_stages for item in stage.scatter_compatibility_slots - ) - if len(set(flow_scatter)) != len(flow_scatter): - return _unsupported("duplicateExplicitFlowAssignment") - if set(flow_scatter) != set(reset_slots): - return _unsupported("incompleteExplicitFlowCoverage") - - program = CausalIRProgram( - CAUSAL_NUMERIC_IR_SCHEMA_VERSION, - tuple(canonical_slots), - compatibility_slots, - reset_slots, - external_slots, - tuple(effort_stages), - tuple(flow_stages), - "", - ) - program = replace( - program, structural_signature=program.calculate_structural_signature() - ) - return CausalIRCompilation( - CausalNumericIR( - program, - CausalIRBindings(readers, writers, tuple(evaluators)), - ), - None, - ) diff --git a/app/simulation/solvers/jacobian.py b/app/simulation/solvers/jacobian.py deleted file mode 100644 index af560a1..0000000 --- a/app/simulation/solvers/jacobian.py +++ /dev/null @@ -1,1478 +0,0 @@ -"""Audited sparse secant Jacobians for implicit ODE solvers. - -This module deliberately has no dependency on ``GenericFluidSystem`` or the -SciPy solver wrappers. It is an experimental, opt-in numerical kernel that can -be wired into BDF/Radau through their callable ``jac`` argument after its model -level eligibility rules have been checked. - -The first Jacobian in every solver segment is a complete, sparsity-coloured -finite-difference build. Ordinary RHS samples at the same time coordinate can -then update that matrix with row-constrained sparse secants. At most one such -matrix is reused before another complete build, and reuse is allowed only after -a deterministic directional finite-difference audit. An audit mismatch falls -back to a complete build in the same call. - -SciPy's private ``num_jac`` and ``group_columns`` helpers are isolated here so -an incompatible SciPy version fails with one explicit compatibility error -rather than changing solver behaviour silently. -""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping, Sequence -import inspect -import math -from time import perf_counter -from typing import TypeAlias - -import numpy as np - -from app.simulation.core.errors import RecoverableTrialStateError - - -JacobianEvaluate: TypeAlias = Callable[[float, np.ndarray], Sequence[float]] -ExactRowValues: TypeAlias = Sequence[float] | Mapping[int, float] -ExactRow: TypeAlias = ExactRowValues | Callable[[float, np.ndarray], ExactRowValues] -ExactColumnValues: TypeAlias = Sequence[float] | np.ndarray -ExactColumnProviderResult: TypeAlias = ( - Sequence[Sequence[float]] | np.ndarray | Mapping[int, ExactColumnValues] -) -ExactColumnProvider: TypeAlias = Callable[ - [float, np.ndarray, tuple[int, ...]], - ExactColumnProviderResult, -] -ExactColumns: TypeAlias = ( - tuple[Sequence[int], ExactColumnProvider] - | Mapping[int, ExactColumnValues] -) - - -class SparseJacobianCompatibilityError(RuntimeError): - """Raised when the installed SciPy cannot provide the expected helpers.""" - - -class ExactColumnsUnavailable(RuntimeError): - """Request a numerical-column fallback for one Jacobian assembly. - - An exact-column provider may raise this exception when its analytical or - tangent calculation is not applicable at the current primal point. The - provider must raise before mutating the shared model closure: the Jacobian - builder deliberately reuses the real base RHS evaluation that immediately - preceded the provider call. - - Shape errors, non-finite values, and all other provider exceptions remain - programming errors and are intentionally not converted into a fallback. - """ - - def __init__(self, reason: str) -> None: - self.reason = str(reason) - super().__init__(self.reason) - - -class SparseSecantJacobian: - """Build and cautiously reuse a sparse numerical ODE Jacobian. - - Parameters - ---------- - evaluate: - Scalar-state RHS evaluator. The callable receives ``(time, state)`` - and must return one derivative per state. It need not be vectorized. - sparsity: - Conservative square Jacobian structure accepted by ``scipy.sparse``. - Missing structural entries cannot be repaired by the secant update. - atol: - Scalar or state-aligned absolute tolerance used by SciPy's numerical - Jacobian step selection. - exact_rows: - Optional complete row providers keyed by row index. A provider (or - constant value) may return either a dense row or a ``column: value`` - mapping. Exact rows are excluded from finite differences and secant - updates, then refreshed at the requested ``(time, state)``. - exact_columns: - Optional ``(column_indexes, provider)`` pair. The provider receives - ``(time, state, normalized_column_indexes)`` and returns a dense - ``(state_count, column_count)`` matrix in that exact column order. An - ordered ``column: dense_column`` mapping is also accepted from the - provider so its returned order can be checked. A fixed mapping may be - supplied directly as a convenience. Exact columns are removed before - seed-0 finite-difference colouring, then complete columns and complete - rows are applied in that order, so ``exact_rows`` wins at intersections. - A callable provider may raise :class:`ExactColumnsUnavailable` before - changing the shared model closure to request a one-build fallback to - the original seed-0 numerical Jacobian. - audit_relative_tolerance: - Maximum component-wise relative mismatch for the deterministic Jv - audit. The audit also includes a floating-point roundoff allowance. - audit_absolute_tolerance: - Optional absolute allowance for the Jv delta comparison. - max_consecutive_reuses: - ``0`` selects complete seed-0 finite-difference builds and disables - observation work. ``1`` enables the experimental audited secant path. - - Notes - ----- - ``observe`` must receive only ordinary RHS evaluations. Calls made by - this object while building or auditing the Jacobian are suppressed - automatically if the supplied evaluator itself reports them back through - ``observe``. - """ - - _MAX_PENDING_SECANT_PAIRS = 4 - # Keep SciPy's deterministic compatibility baseline. Alternative greedy - # orders can change finite-difference perturbations and therefore event - # sequences in non-smooth models, even if they use fewer colour groups. - _COLORING_SEED = 0 - - def __init__( - self, - evaluate: JacobianEvaluate, - sparsity: object, - atol: float | Sequence[float], - exact_rows: Mapping[int, ExactRow] | None = None, - exact_columns: ExactColumns | None = None, - *, - audit_relative_tolerance: float = 5.0e-2, - audit_absolute_tolerance: float = 0.0, - max_consecutive_reuses: int = 1, - ) -> None: - try: - from scipy.sparse import csc_matrix - except ImportError as exc: # pragma: no cover - project requires SciPy. - raise SparseJacobianCompatibilityError( - "Sparse secant Jacobians require scipy.sparse." - ) from exc - - if not callable(evaluate): - raise TypeError("evaluate must be callable.") - self._evaluate_rhs = evaluate - - structure = csc_matrix(sparsity, dtype=bool) - structure.eliminate_zeros() - if len(structure.shape) != 2 or structure.shape[0] != structure.shape[1]: - raise ValueError("Jacobian sparsity must be a square matrix.") - if structure.shape[0] == 0: - raise ValueError("Jacobian sparsity must contain at least one state.") - self._state_count = int(structure.shape[0]) - tolerance = np.asarray(atol, dtype=float) - if tolerance.ndim == 0: - tolerance = np.full(self._state_count, float(tolerance), dtype=float) - if tolerance.shape != (self._state_count,): - raise ValueError( - "atol must be scalar or contain one value per Jacobian state." - ) - if not np.all(np.isfinite(tolerance)) or np.any(tolerance <= 0.0): - raise ValueError("atol values must be finite and greater than zero.") - self._atol = tolerance - - if ( - not math.isfinite(audit_relative_tolerance) - or audit_relative_tolerance < 0.0 - ): - raise ValueError( - "audit_relative_tolerance must be finite and non-negative." - ) - if ( - not math.isfinite(audit_absolute_tolerance) - or audit_absolute_tolerance < 0.0 - ): - raise ValueError( - "audit_absolute_tolerance must be finite and non-negative." - ) - self._audit_relative_tolerance = float(audit_relative_tolerance) - self._audit_absolute_tolerance = float(audit_absolute_tolerance) - if ( - not isinstance(max_consecutive_reuses, int) - or isinstance(max_consecutive_reuses, bool) - or max_consecutive_reuses not in {0, 1} - ): - raise ValueError("max_consecutive_reuses must be either 0 or 1.") - self._max_consecutive_reuses = max_consecutive_reuses - - self._exact_rows = dict(exact_rows or {}) - for row_index in self._exact_rows: - if ( - not isinstance(row_index, int) - or isinstance(row_index, bool) - or not 0 <= row_index < self._state_count - ): - raise ValueError( - f"Exact Jacobian row index {row_index!r} is out of range." - ) - self._exact_row_indexes = frozenset(self._exact_rows) - - self._exact_column_provider: ExactColumnProvider | None = None - self._fixed_exact_column_values: np.ndarray | None = None - if exact_columns is None: - exact_column_indexes: tuple[int, ...] = () - elif isinstance(exact_columns, Mapping): - exact_column_indexes = self._normalize_exact_column_indexes( - exact_columns.keys() - ) - fixed_columns: list[np.ndarray] = [] - for column_index in exact_column_indexes: - column = np.asarray(exact_columns[column_index], dtype=float) - if column.shape != (self._state_count,): - raise ValueError( - f"Exact Jacobian column {column_index} must have shape " - f"{(self._state_count,)}, received {column.shape}." - ) - if not np.all(np.isfinite(column)): - raise ValueError( - "Exact Jacobian columns must contain finite values." - ) - fixed_columns.append(column.copy()) - self._fixed_exact_column_values = ( - np.column_stack(fixed_columns) - if fixed_columns - else np.empty((self._state_count, 0), dtype=float) - ) - else: - if not isinstance(exact_columns, tuple) or len(exact_columns) != 2: - raise TypeError( - "exact_columns must be a fixed mapping or a " - "(column_indexes, provider) pair." - ) - requested_columns, provider = exact_columns - if not callable(provider): - raise TypeError("The exact Jacobian column provider must be callable.") - exact_column_indexes = self._normalize_exact_column_indexes( - requested_columns - ) - self._exact_column_provider = provider - self._exact_column_indexes = exact_column_indexes - self._exact_column_index_set = frozenset(exact_column_indexes) - self._exact_column_pattern = np.asarray( - structure[:, list(exact_column_indexes)].toarray(), - dtype=bool, - ) - self._exact_column_outside_pattern_nonzero_count = 0 - - self._original_sparsity = structure.copy() - finite_difference_sparsity = structure.tolil(copy=True) - for row_index in self._exact_row_indexes: - finite_difference_sparsity.rows[row_index] = [] - finite_difference_sparsity.data[row_index] = [] - self._original_finite_difference_sparsity = ( - finite_difference_sparsity.tocsc().astype(bool) - ) - self._remaining_finite_difference_sparsity = ( - self._original_finite_difference_sparsity.copy() - ) - for column_index in self._exact_column_indexes: - start = int( - self._remaining_finite_difference_sparsity.indptr[column_index] - ) - stop = int( - self._remaining_finite_difference_sparsity.indptr[ - column_index + 1 - ] - ) - self._remaining_finite_difference_sparsity.data[start:stop] = False - self._remaining_finite_difference_sparsity.eliminate_zeros() - remaining_columns = np.asarray( - self._remaining_finite_difference_sparsity.getnnz(axis=0) - ).reshape(-1) > 0 - self._remaining_finite_difference_columns = np.flatnonzero( - remaining_columns - ) - self._finite_difference_column_count = int( - self._remaining_finite_difference_columns.size - ) - - self._num_jac, group_columns = self._load_scipy_helpers() - if self._exact_column_indexes: - self._validate_subset_sparse_compatibility() - self._original_groups = np.asarray( - group_columns( - self._original_sparsity, - order=self._COLORING_SEED, - ), - dtype=int, - ) - if self._original_groups.shape != (self._state_count,): - raise SparseJacobianCompatibilityError( - "SciPy Jacobian column grouping returned an unexpected shape." - ) - self._original_color_group_count = ( - int(self._original_groups.max(initial=-1)) + 1 - ) - - if self._exact_column_indexes: - # True subset finite differences: exact and otherwise inactive - # columns are absent from both colouring and perturbation batches. - grouped_columns = self._remaining_finite_difference_columns - grouping_structure = self._remaining_finite_difference_sparsity[ - :, grouped_columns - ] - self._remaining_groups = np.full( - self._state_count, - -1, - dtype=int, - ) - else: - # Preserve the established SciPy seed-0 baseline when no exact - # columns are requested, including the existing exact-row policy. - grouped_columns = np.empty(0, dtype=int) - grouping_structure = None - self._remaining_groups = self._original_groups.copy() - if grouped_columns.size: - remaining_groups = np.asarray( - group_columns( - grouping_structure, - order=self._COLORING_SEED, - ), - dtype=int, - ) - if remaining_groups.shape != (grouped_columns.size,): - raise SparseJacobianCompatibilityError( - "SciPy Jacobian column grouping returned an unexpected shape." - ) - self._remaining_groups[grouped_columns] = remaining_groups - self._remaining_color_group_count = ( - int(remaining_groups.max(initial=-1)) + 1 - ) - elif self._exact_column_indexes: - self._remaining_color_group_count = 0 - else: - self._remaining_color_group_count = self._original_color_group_count - if self._remaining_groups.shape != (self._state_count,): - raise SparseJacobianCompatibilityError( - "SciPy Jacobian column grouping returned an unexpected shape." - ) - self._coloring_seed = self._COLORING_SEED - self._color_group_count = self._remaining_color_group_count - self._default_color_group_count = self._remaining_color_group_count - - self._jacobian = None - self._original_factor = None - self._remaining_factor = None - self._last_factor = None - self._constructing = False - self._has_secant_update = False - self._consecutive_reuses = 0 - self._pending_secants: list[ - tuple[np.ndarray, np.ndarray, np.ndarray] - ] = [] - self._last_observation_time: float | None = None - self._last_observation_state: np.ndarray | None = None - self._last_observation_rhs: np.ndarray | None = None - - self._full_build_count = 0 - self._secant_reuse_count = 0 - self._audit_failure_count = 0 - self._finite_difference_rhs_count = 0 - self._base_rhs_count = 0 - self._jv_audit_count = 0 - self._secant_update_count = 0 - self._rejected_observation_count = 0 - self._segment_start_count = 0 - self._jacobian_evaluation_count = 0 - self._assembly_seconds = 0.0 - self._exact_column_build_count = 0 - self._exact_column_fallback_count = 0 - self._last_exact_column_fallback_reason: str | None = None - self._last_build_finite_difference_rhs_count = 0 - self._last_decision = "uninitialized" - self._last_audit_relative_error: float | None = None - self._last_audit_rms_relative_error: float | None = None - self._last_audit_p95_relative_error: float | None = None - self._segments: list[dict[str, int | float]] = [] - - def _normalize_exact_column_indexes( - self, - values: Sequence[int] | object, - ) -> tuple[int, ...]: - try: - requested = tuple(values) # type: ignore[arg-type] - except TypeError as exc: - raise TypeError( - "Exact Jacobian column indexes must be an iterable of integers." - ) from exc - seen: set[int] = set() - for column_index in requested: - if ( - not isinstance(column_index, int) - or isinstance(column_index, bool) - or not 0 <= column_index < self._state_count - ): - raise ValueError( - f"Exact Jacobian column index {column_index!r} is out of range." - ) - if column_index in seen: - raise ValueError( - f"Exact Jacobian column index {column_index} is duplicated." - ) - seen.add(column_index) - return tuple(sorted(seen)) - - @staticmethod - def _load_scipy_helpers(): - try: - from scipy.integrate._ivp.common import num_jac - from scipy.optimize._numdiff import group_columns - except (ImportError, AttributeError) as exc: - raise SparseJacobianCompatibilityError( - "The installed SciPy does not expose compatible numerical " - "Jacobian helpers." - ) from exc - - expected_num_jac = { - "fun", - "t", - "y", - "f", - "threshold", - "factor", - "sparsity", - } - expected_group_columns = {"A", "order"} - try: - num_jac_parameters = set(inspect.signature(num_jac).parameters) - group_parameters = set(inspect.signature(group_columns).parameters) - except (TypeError, ValueError) as exc: - raise SparseJacobianCompatibilityError( - "Unable to inspect SciPy numerical Jacobian helper signatures." - ) from exc - if not expected_num_jac.issubset(num_jac_parameters) or not ( - expected_group_columns.issubset(group_parameters) - ): - raise SparseJacobianCompatibilityError( - "The installed SciPy numerical Jacobian helper API is unsupported." - ) - return num_jac, group_columns - - @staticmethod - def _validate_subset_sparse_compatibility() -> None: - """Fail before integration when subset-FD private helpers are absent.""" - - try: - from scipy.integrate._ivp.common import ( - EPS, - NUM_JAC_DIFF_BIG, - NUM_JAC_DIFF_REJECT, - NUM_JAC_DIFF_SMALL, - NUM_JAC_FACTOR_DECREASE, - NUM_JAC_FACTOR_INCREASE, - NUM_JAC_MIN_FACTOR, - ) - from scipy.sparse import coo_matrix, csc_matrix, find - except (ImportError, AttributeError) as exc: - raise SparseJacobianCompatibilityError( - "The installed SciPy does not expose compatible subset " - "Jacobian helpers." - ) from exc - del ( - EPS, - NUM_JAC_DIFF_BIG, - NUM_JAC_DIFF_REJECT, - NUM_JAC_DIFF_SMALL, - NUM_JAC_FACTOR_DECREASE, - NUM_JAC_FACTOR_INCREASE, - NUM_JAC_MIN_FACTOR, - coo_matrix, - csc_matrix, - find, - ) - - def _subset_sparse_num_jac( - self, - fun, - time: float, - state: np.ndarray, - base_rhs: np.ndarray, - ): - """Run SciPy-compatible adaptive differences on active columns only.""" - - try: - from scipy.integrate._ivp.common import ( - EPS, - NUM_JAC_DIFF_BIG, - NUM_JAC_DIFF_REJECT, - NUM_JAC_DIFF_SMALL, - NUM_JAC_FACTOR_DECREASE, - NUM_JAC_FACTOR_INCREASE, - NUM_JAC_MIN_FACTOR, - ) - from scipy.sparse import coo_matrix, csc_matrix, find - except (ImportError, AttributeError) as exc: # pragma: no cover - raise SparseJacobianCompatibilityError( - "The installed SciPy does not expose compatible numerical " - "Jacobian constants." - ) from exc - - active_columns = self._remaining_finite_difference_columns - active_count = int(active_columns.size) - if active_count == 0: # pragma: no cover - guarded by _full_build. - return csc_matrix( - (self._state_count, self._state_count), dtype=float - ), self._remaining_factor - - if self._remaining_factor is None: - factor = np.full(self._state_count, EPS**0.5, dtype=float) - else: - factor = np.asarray(self._remaining_factor, dtype=float).copy() - if factor.shape != (self._state_count,): - raise SparseJacobianCompatibilityError( - "SciPy numerical Jacobian factor has an unexpected shape." - ) - - rhs_sign = 2.0 * (np.real(base_rhs) >= 0.0).astype(float) - 1.0 - state_scale = rhs_sign * np.maximum(self._atol, np.abs(state)) - step = np.zeros(self._state_count, dtype=float) - step[active_columns] = ( - state[active_columns] - + factor[active_columns] * state_scale[active_columns] - ) - state[active_columns] - for column_index in active_columns[step[active_columns] == 0.0]: - while step[column_index] == 0.0: - factor[column_index] *= 10.0 - step[column_index] = ( - state[column_index] - + factor[column_index] * state_scale[column_index] - ) - state[column_index] - - groups = self._remaining_groups[active_columns] - group_count = self._remaining_color_group_count - perturbations = np.zeros((self._state_count, group_count), dtype=float) - perturbations[active_columns, groups] = step[active_columns] - perturbed_rhs = fun(time, state[:, None] + perturbations) - differences = perturbed_rhs - base_rhs[:, None] - - reduced_structure = self._remaining_finite_difference_sparsity[ - :, active_columns - ] - row_indexes, reduced_columns, _values = find(reduced_structure) - reduced_jacobian = coo_matrix( - ( - differences[row_indexes, groups[reduced_columns]], - (row_indexes, reduced_columns), - ), - shape=(self._state_count, active_count), - ).tocsc() - maximum_rows = np.asarray( - abs(reduced_jacobian).argmax(axis=0) - ).ravel() - reduced_range = np.arange(active_count) - maximum_difference = np.asarray( - np.abs(reduced_jacobian[maximum_rows, reduced_range]) - ).ravel() - difference_scale = np.maximum( - np.abs(base_rhs[maximum_rows]), - np.abs(perturbed_rhs[maximum_rows, groups]), - ) - - too_small = maximum_difference < NUM_JAC_DIFF_REJECT * difference_scale - if np.any(too_small): - rejected_reduced_columns = np.flatnonzero(too_small) - rejected_columns = active_columns[rejected_reduced_columns] - increased_factor = ( - NUM_JAC_FACTOR_INCREASE * factor[rejected_columns] - ) - increased_step = ( - state[rejected_columns] - + increased_factor * state_scale[rejected_columns] - ) - state[rejected_columns] - increased_step_by_column = np.zeros(self._state_count, dtype=float) - increased_step_by_column[rejected_columns] = increased_step - - rejected_groups = np.unique(groups[rejected_reduced_columns]) - group_map = np.full(group_count, -1, dtype=int) - retry_perturbations = np.zeros( - (self._state_count, rejected_groups.size), - dtype=float, - ) - for retry_index, group in enumerate(rejected_groups): - group_map[group] = retry_index - group_columns = active_columns[groups == group] - retry_perturbations[group_columns, retry_index] = ( - increased_step_by_column[group_columns] - ) - - retry_rhs = fun(time, state[:, None] + retry_perturbations) - retry_differences = retry_rhs - base_rhs[:, None] - retry_structure = reduced_structure[:, rejected_reduced_columns] - retry_rows, retry_columns, _retry_values = find(retry_structure) - retry_group_columns = rejected_reduced_columns[retry_columns] - retry_jacobian = coo_matrix( - ( - retry_differences[ - retry_rows, - group_map[groups[retry_group_columns]], - ], - (retry_rows, retry_columns), - ), - shape=(self._state_count, rejected_reduced_columns.size), - ).tocsc() - retry_maximum_rows = np.asarray( - abs(retry_jacobian).argmax(axis=0) - ).ravel() - retry_range = np.arange(rejected_reduced_columns.size) - retry_maximum_difference = np.asarray( - np.abs(retry_jacobian[retry_maximum_rows, retry_range]) - ).ravel() - retry_scale = np.maximum( - np.abs(base_rhs[retry_maximum_rows]), - np.abs( - retry_rhs[ - retry_maximum_rows, - group_map[groups[rejected_reduced_columns]], - ] - ), - ) - use_retry = ( - maximum_difference[rejected_reduced_columns] * retry_scale - < retry_maximum_difference - * difference_scale[rejected_reduced_columns] - ) - if np.any(use_retry): - retry_selection = np.flatnonzero(use_retry) - selected_reduced_columns = rejected_reduced_columns[ - retry_selection - ] - selected_columns = active_columns[selected_reduced_columns] - factor[selected_columns] = increased_factor[retry_selection] - step[selected_columns] = increased_step[retry_selection] - reduced_jacobian[:, selected_reduced_columns] = retry_jacobian[ - :, retry_selection - ] - difference_scale[selected_reduced_columns] = retry_scale[ - retry_selection - ] - maximum_difference[selected_reduced_columns] = ( - retry_maximum_difference[retry_selection] - ) - - reduced_jacobian.data /= np.repeat( - step[active_columns], - np.diff(reduced_jacobian.indptr), - ) - factor[ - active_columns[ - maximum_difference < NUM_JAC_DIFF_SMALL * difference_scale - ] - ] *= NUM_JAC_FACTOR_INCREASE - factor[ - active_columns[ - maximum_difference > NUM_JAC_DIFF_BIG * difference_scale - ] - ] *= NUM_JAC_FACTOR_DECREASE - factor = np.maximum(factor, NUM_JAC_MIN_FACTOR) - - rows, reduced_columns, values = find(reduced_jacobian) - jacobian = csc_matrix( - ( - values, - (rows, active_columns[reduced_columns]), - ), - shape=(self._state_count, self._state_count), - ) - return jacobian, factor - - @staticmethod - def _new_segment_diagnostics(index: int) -> dict[str, int | float]: - return { - "index": index, - "fullBuildCount": 0, - "secantReuseCount": 0, - "auditFailureCount": 0, - "exactColumnBuildCount": 0, - "exactColumnFallbackCount": 0, - "finiteDifferenceRhsEvaluationCount": 0, - "baseRhsEvaluationCount": 0, - "jvAuditRhsEvaluationCount": 0, - "jacobianEvaluationCount": 0, - "secantPairCount": 0, - "assemblySeconds": 0.0, - } - - def _record_segment(self, key: str, value: int | float = 1) -> None: - if not self._segments: - self._segments.append(self._new_segment_diagnostics(0)) - segment = self._segments[-1] - segment[key] = segment[key] + value - - def start_segment(self) -> None: - """Discard numerical state before a breakpoint/event solver restart.""" - - self._jacobian = None - self._original_factor = None - self._remaining_factor = None - self._last_factor = None - self._has_secant_update = False - self._consecutive_reuses = 0 - self._pending_secants.clear() - self._clear_observation() - self._segment_start_count += 1 - if not self._segments: - self._segments.append(self._new_segment_diagnostics(0)) - elif any( - value - for key, value in self._segments[-1].items() - if key != "index" - ): - self._segments.append( - self._new_segment_diagnostics(len(self._segments)) - ) - self._last_decision = "segmentReset" - self._last_audit_relative_error = None - self._last_audit_rms_relative_error = None - self._last_audit_p95_relative_error = None - - def _clear_observation(self) -> None: - self._last_observation_time = None - self._last_observation_state = None - self._last_observation_rhs = None - - def _state_array(self, values: Sequence[float] | np.ndarray) -> np.ndarray: - state = np.asarray(values, dtype=float) - if state.shape != (self._state_count,): - raise ValueError( - f"Expected a state vector of shape {(self._state_count,)}, " - f"received {state.shape}." - ) - if not np.all(np.isfinite(state)): - raise ValueError("Jacobian state vectors must contain finite values.") - return state - - def _rhs_array(self, values: Sequence[float] | np.ndarray) -> np.ndarray: - derivative = np.asarray(values, dtype=float) - if derivative.shape != (self._state_count,): - raise ValueError( - f"Expected an RHS vector of shape {(self._state_count,)}, " - f"received {derivative.shape}." - ) - if not np.all(np.isfinite(derivative)): - raise ValueError("Jacobian RHS evaluations must contain finite values.") - return derivative - - def _evaluate( - self, - time: float, - state: np.ndarray, - *, - purpose: str, - ) -> np.ndarray: - if purpose == "base": - self._base_rhs_count += 1 - self._record_segment("baseRhsEvaluationCount") - elif purpose == "finiteDifference": - self._finite_difference_rhs_count += 1 - self._record_segment("finiteDifferenceRhsEvaluationCount") - elif purpose == "jvAudit": - self._jv_audit_count += 1 - self._record_segment("jvAuditRhsEvaluationCount") - else: # pragma: no cover - internal programming error. - raise RuntimeError(f"Unknown Jacobian evaluation purpose: {purpose}.") - return self._rhs_array(self._evaluate_rhs(float(time), state.copy())) - - def _remember_observation( - self, - time: float, - state: np.ndarray, - derivative: np.ndarray, - ) -> None: - self._last_observation_time = float(time) - self._last_observation_state = state.copy() - self._last_observation_rhs = derivative.copy() - - def observe( - self, - time: float, - state: Sequence[float] | np.ndarray, - derivative: Sequence[float] | np.ndarray, - ) -> None: - """Cache one ordinary RHS sample for a later same-time sparse secant.""" - - if self._constructing or self._max_consecutive_reuses == 0: - return - state_array = self._state_array(state) - derivative_array = self._rhs_array(derivative) - time_value = float(time) - if not math.isfinite(time_value): - raise ValueError("Jacobian observation time must be finite.") - - previous_state = self._last_observation_state - previous_rhs = self._last_observation_rhs - same_time = ( - self._last_observation_time is not None - and time_value == self._last_observation_time - ) - if not same_time: - self._pending_secants.clear() - if ( - self._jacobian is None - or not same_time - or previous_state is None - or previous_rhs is None - ): - self._remember_observation(time_value, state_array, derivative_array) - return - - state_delta = state_array - previous_state - rhs_delta = derivative_array - previous_rhs - state_scale = np.maximum.reduce( - ( - np.abs(state_array), - np.abs(previous_state), - self._atol, - ) - ) - normalized_step = np.max(np.abs(state_delta) / state_scale) - if ( - not math.isfinite(float(normalized_step)) - or normalized_step <= 32.0 * np.finfo(float).eps - ): - self._rejected_observation_count += 1 - self._remember_observation(time_value, state_array, derivative_array) - return - - self._pending_secants.append( - (state_delta.copy(), rhs_delta.copy(), state_scale.copy()) - ) - if len(self._pending_secants) > self._MAX_PENDING_SECANT_PAIRS: - del self._pending_secants[0] - self._record_segment("secantPairCount") - self._remember_observation(time_value, state_array, derivative_array) - - def _apply_pending_secants(self) -> None: - """Apply cached same-time pairs only when a new Jacobian is requested.""" - - if self._jacobian is None or not self._pending_secants: - return - jacobian = self._jacobian.tocsr(copy=True) - updated_row_count = 0 - for state_delta, rhs_delta, state_scale in self._pending_secants: - normalized_step = state_delta / state_scale - for row_index in range(self._state_count): - if row_index in self._exact_row_indexes: - continue - start = int(jacobian.indptr[row_index]) - stop = int(jacobian.indptr[row_index + 1]) - if start == stop: - continue - columns = jacobian.indices[start:stop] - mutable = np.asarray( - [ - column_index not in self._exact_column_index_set - for column_index in columns - ], - dtype=bool, - ) - if not np.any(mutable): - continue - mutable_columns = columns[mutable] - local_normalized_step = normalized_step[mutable_columns] - denominator = float( - np.dot(local_normalized_step, local_normalized_step) - ) - if not math.isfinite(denominator) or denominator <= 0.0: - continue - row_values = jacobian.data[start:stop] - predicted_delta = float( - np.dot(row_values, state_delta[columns]) - ) - correction_scale = ( - float(rhs_delta[row_index]) - predicted_delta - ) / denominator - # Apply Broyden to A = J * diag(state_scale), then transform - # the corrected row back to the physical Jacobian coordinates. - correction = ( - correction_scale - * local_normalized_step - / state_scale[mutable_columns] - ) - if not np.all(np.isfinite(correction)): - continue - row_values[mutable] = row_values[mutable] + correction - updated_row_count += 1 - - self._pending_secants.clear() - - if updated_row_count: - self._jacobian = jacobian - self._has_secant_update = True - self._secant_update_count += 1 - else: - self._rejected_observation_count += 1 - - def _resolve_exact_row( - self, - row_index: int, - time: float, - state: np.ndarray, - ) -> np.ndarray: - specification = self._exact_rows[row_index] - values = ( - specification(time, state.copy()) - if callable(specification) - else specification - ) - row = np.zeros(self._state_count, dtype=float) - if isinstance(values, Mapping): - for column_index, value in values.items(): - if ( - not isinstance(column_index, int) - or isinstance(column_index, bool) - or not 0 <= column_index < self._state_count - ): - raise ValueError( - f"Exact Jacobian column index {column_index!r} is out of range." - ) - row[column_index] = float(value) - else: - dense_values = np.asarray(values, dtype=float) - if dense_values.shape != (self._state_count,): - raise ValueError( - f"Exact Jacobian row {row_index} must have shape " - f"{(self._state_count,)}, received {dense_values.shape}." - ) - row[:] = dense_values - if not np.all(np.isfinite(row)): - raise ValueError("Exact Jacobian rows must contain finite values.") - return row - - def _with_exact_rows(self, jacobian, time: float, state: np.ndarray): - if not self._exact_rows: - return jacobian.tocsr(copy=True) - result = jacobian.tolil(copy=True) - for row_index in sorted(self._exact_rows): - row = self._resolve_exact_row(row_index, time, state) - columns = np.flatnonzero(row) - result.rows[row_index] = [int(column) for column in columns] - result.data[row_index] = [float(row[column]) for column in columns] - return result.tocsr() - - def _resolve_exact_columns( - self, - time: float, - state: np.ndarray, - ) -> np.ndarray: - column_count = len(self._exact_column_indexes) - if column_count == 0: - self._exact_column_outside_pattern_nonzero_count = 0 - return np.empty((self._state_count, 0), dtype=float) - - if self._fixed_exact_column_values is not None: - columns = self._fixed_exact_column_values.copy() - else: - assert self._exact_column_provider is not None - values = self._exact_column_provider( - float(time), - state.copy(), - self._exact_column_indexes, - ) - if isinstance(values, Mapping): - returned_indexes = tuple(values) - if returned_indexes != self._exact_column_indexes: - raise ValueError( - "Exact Jacobian column provider mapping keys must match " - "the requested normalized column order." - ) - provider_columns: list[np.ndarray] = [] - for column_index in returned_indexes: - column = np.asarray(values[column_index], dtype=float) - if column.shape != (self._state_count,): - raise ValueError( - f"Exact Jacobian column {column_index} must have shape " - f"{(self._state_count,)}, received {column.shape}." - ) - provider_columns.append(column) - columns = np.column_stack(provider_columns) - else: - columns = np.asarray(values, dtype=float) - - expected_shape = (self._state_count, column_count) - if columns.shape != expected_shape: - raise ValueError( - "Exact Jacobian column provider must return shape " - f"{expected_shape}, received {columns.shape}." - ) - if not np.all(np.isfinite(columns)): - raise ValueError("Exact Jacobian columns must contain finite values.") - self._exact_column_outside_pattern_nonzero_count = int( - np.count_nonzero((columns != 0.0) & ~self._exact_column_pattern) - ) - return columns - - def _evaluate_exact_column_base_rhs( - self, - time: float, - state: np.ndarray, - ) -> np.ndarray: - """Evaluate a base RHS while allowing a provider one-shot capture.""" - - request_capture = getattr( - self._exact_column_provider, - "request_primal_capture", - None, - ) - cancel_capture = getattr( - self._exact_column_provider, - "cancel_primal_capture", - None, - ) - try: - if request_capture is not None: - request_capture() - return self._evaluate(time, state, purpose="base") - finally: - if cancel_capture is not None: - cancel_capture() - - def _with_exact_columns( - self, - jacobian, - time: float, - state: np.ndarray, - *, - resolved_columns: np.ndarray | None = None, - ): - if not self._exact_column_indexes: - self._exact_column_outside_pattern_nonzero_count = 0 - return jacobian.tocsr(copy=True) - - from scipy.sparse import csc_matrix, diags - - columns = ( - self._resolve_exact_columns(time, state) - if resolved_columns is None - else resolved_columns - ) - keep_column = np.ones(self._state_count, dtype=float) - keep_column[list(self._exact_column_indexes)] = 0.0 - remaining = jacobian.tocsc(copy=True) @ diags( - keep_column, - format="csc", - ) - row_indexes = np.tile( - np.arange(self._state_count, dtype=int), - len(self._exact_column_indexes), - ) - column_indexes = np.repeat( - np.asarray(self._exact_column_indexes, dtype=int), - self._state_count, - ) - exact = csc_matrix( - ( - columns.ravel(order="F"), - (row_indexes, column_indexes), - ), - shape=(self._state_count, self._state_count), - ) - exact.eliminate_zeros() - return (remaining + exact).tocsr() - - def _with_exact_overrides( - self, - jacobian, - time: float, - state: np.ndarray, - *, - resolved_columns: np.ndarray | None = None, - ): - # Full columns are assembled first. Full rows intentionally win at - # row/column intersections, matching the documented precedence. - with_columns = self._with_exact_columns( - jacobian, - time, - state, - resolved_columns=resolved_columns, - ) - return self._with_exact_rows(with_columns, time, state) - - def _record_exact_column_fallback( - self, - unavailable: ExactColumnsUnavailable, - ) -> None: - self._exact_column_fallback_count += 1 - self._last_exact_column_fallback_reason = unavailable.reason - self._record_segment("exactColumnFallbackCount") - - def _full_build( - self, - time: float, - state: np.ndarray, - *, - decision: str = "fullBuild", - base_rhs: np.ndarray | None = None, - exact_column_unavailable: ExactColumnsUnavailable | None = None, - ): - from scipy.sparse import csr_matrix - - finite_difference_rhs_before = self._finite_difference_rhs_count - self._constructing = True - try: - resolved_exact_columns = None - unavailable = exact_column_unavailable - if self._exact_column_indexes and unavailable is None: - # Tangent providers operate on the closure left by a real - # primal RHS at exactly (time, state). Resolve them before any - # grouped perturbation dirties the shared model. - if base_rhs is None: - base_rhs = self._evaluate_exact_column_base_rhs( - time, - state, - ) - try: - resolved_exact_columns = self._resolve_exact_columns( - time, - state, - ) - except ExactColumnsUnavailable as exc: - unavailable = exc - - use_exact_columns = bool( - self._exact_column_indexes and unavailable is None - ) - if unavailable is not None: - self._record_exact_column_fallback(unavailable) - self._exact_column_outside_pattern_nonzero_count = 0 - if decision == "fullBuild": - decision = "exactColumnFallback" - - finite_difference_sparsity = ( - self._remaining_finite_difference_sparsity - if use_exact_columns - else self._original_finite_difference_sparsity - ) - if finite_difference_sparsity.nnz == 0: - selected_factor = ( - self._remaining_factor - if use_exact_columns - else self._original_factor - ) - self._last_factor = ( - None if selected_factor is None else selected_factor.copy() - ) - jacobian = csr_matrix( - (self._state_count, self._state_count), dtype=float - ) - else: - if base_rhs is None: - base_rhs = self._evaluate(time, state, purpose="base") - - def vectorized_evaluate( - evaluation_time: float, - states: np.ndarray, - ) -> np.ndarray: - states_array = np.asarray(states, dtype=float) - if states_array.ndim == 1: - return self._evaluate( - evaluation_time, - states_array.copy(), - purpose="finiteDifference", - ) - if ( - states_array.ndim != 2 - or states_array.shape[0] != self._state_count - ): - raise ValueError( - "SciPy requested an unexpected vectorized Jacobian shape." - ) - return np.column_stack( - [ - self._evaluate( - evaluation_time, - states_array[:, column_index].copy(), - purpose="finiteDifference", - ) - for column_index in range(states_array.shape[1]) - ] - ) - - if use_exact_columns: - jacobian, self._remaining_factor = self._subset_sparse_num_jac( - vectorized_evaluate, - float(time), - state, - base_rhs, - ) - self._last_factor = self._remaining_factor.copy() - else: - jacobian, self._original_factor = self._num_jac( - fun=vectorized_evaluate, - t=float(time), - y=state, - f=base_rhs, - threshold=self._atol, - factor=self._original_factor, - sparsity=( - self._original_finite_difference_sparsity, - self._original_groups, - ), - ) - self._last_factor = self._original_factor.copy() - jacobian = csr_matrix(jacobian, dtype=float) - if not np.all(np.isfinite(jacobian.data)): - raise ValueError( - "Numerical Jacobian construction returned non-finite values." - ) - if use_exact_columns: - jacobian = self._with_exact_overrides( - jacobian, - time, - state, - resolved_columns=resolved_exact_columns, - ) - else: - # This is exactly the no-provider seed-0 numerical path. Full - # rows may still overwrite their numerical values, but no - # reduced column pattern leaks into the fallback assembly. - jacobian = self._with_exact_rows(jacobian, time, state) - finally: - self._constructing = False - - self._jacobian = jacobian - if use_exact_columns: - self._exact_column_build_count += 1 - self._record_segment("exactColumnBuildCount") - self._last_build_finite_difference_rhs_count = ( - self._finite_difference_rhs_count - finite_difference_rhs_before - ) - self._full_build_count += 1 - self._record_segment("fullBuildCount") - self._has_secant_update = False - self._consecutive_reuses = 0 - self._pending_secants.clear() - self._last_decision = decision - if decision != "auditFallback": - self._last_audit_relative_error = None - self._last_audit_rms_relative_error = None - self._last_audit_p95_relative_error = None - return jacobian.copy() - - def _audit_step(self, state: np.ndarray) -> np.ndarray: - epsilon_root = math.sqrt(np.finfo(float).eps) - factor = ( - np.asarray(self._last_factor, dtype=float) - if self._last_factor is not None - else None - ) - if factor is None or factor.shape != (self._state_count,): - factor = np.full(self._state_count, epsilon_root, dtype=float) - factor = np.where( - np.isfinite(factor) & (factor > 0.0), - factor, - epsilon_root, - ) - magnitude = factor * np.maximum(np.abs(state), self._atol) - indexes = np.arange(self._state_count) - signs = np.where(indexes % 2 == 0, 1.0, -1.0) - # Do not cross zero for tiny positive masses/energies or other bounded - # coordinates. Away from zero, alternating signs reduce cancellation. - close_to_zero = np.abs(state) <= 4.0 * magnitude - signs = np.where( - close_to_zero, - np.where(state < 0.0, -1.0, 1.0), - signs, - ) - requested_step = signs * magnitude - return (state + requested_step) - state - - def _audit_candidate( - self, - time: float, - state: np.ndarray, - jacobian, - *, - base_rhs: np.ndarray | None = None, - ) -> tuple[bool, np.ndarray | None]: - try: - if base_rhs is None: - base_rhs = self._evaluate(time, state, purpose="base") - step = self._audit_step(state) - perturbed_rhs = self._evaluate( - time, - state + step, - purpose="jvAudit", - ) - except RecoverableTrialStateError: - self._last_audit_relative_error = math.inf - self._last_audit_rms_relative_error = math.inf - self._last_audit_p95_relative_error = math.inf - return False, base_rhs - - observed_delta = perturbed_rhs - base_rhs - predicted_delta = np.asarray(jacobian @ step, dtype=float).reshape(-1) - audited_rows = np.ones(self._state_count, dtype=bool) - if self._exact_row_indexes: - audited_rows[list(self._exact_row_indexes)] = False - if not np.any(audited_rows): - self._last_audit_relative_error = 0.0 - self._last_audit_rms_relative_error = 0.0 - self._last_audit_p95_relative_error = 0.0 - return True, base_rhs - - observed = observed_delta[audited_rows] - predicted = predicted_delta[audited_rows] - error = np.abs(observed - predicted) - delta_scale = np.maximum(np.abs(observed), np.abs(predicted)) - rhs_scale = np.maximum( - np.abs(base_rhs[audited_rows]), - np.abs(perturbed_rhs[audited_rows]), - ) - roundoff_allowance = 64.0 * np.finfo(float).eps * np.maximum( - rhs_scale, - 1.0, - ) - tolerance = ( - self._audit_absolute_tolerance - + roundoff_allowance - + self._audit_relative_tolerance * delta_scale - ) - informative = delta_scale > ( - roundoff_allowance + self._audit_absolute_tolerance - ) - # A direction whose observed and predicted deltas are both buried in - # closure/floating-point noise did not validate the matrix. Treat it - # as inconclusive and refresh instead of accepting a vacuous 0 == 0. - passed = bool(np.any(informative) and np.all(error <= tolerance)) - relative_denominator = np.maximum( - delta_scale, - roundoff_allowance + self._audit_absolute_tolerance, - ) - relative_error = error / relative_denominator - self._last_audit_relative_error = float( - np.max(relative_error, initial=0.0) - ) - self._last_audit_rms_relative_error = float( - np.sqrt(np.mean(relative_error * relative_error)) - ) - self._last_audit_p95_relative_error = float( - np.percentile(relative_error, 95.0) - ) - return passed, base_rhs - - def _evaluate_jacobian( - self, - time_value: float, - state_array: np.ndarray, - ): - if self._jacobian is None: - return self._full_build(time_value, state_array) - - if self._consecutive_reuses >= self._max_consecutive_reuses: - return self._full_build(time_value, state_array) - - self._apply_pending_secants() - if not self._has_secant_update: - return self._full_build(time_value, state_array) - - exact_base_rhs = None - resolved_exact_columns = None - if self._exact_column_indexes: - exact_base_rhs = self._evaluate_exact_column_base_rhs( - time_value, - state_array, - ) - try: - resolved_exact_columns = self._resolve_exact_columns( - time_value, - state_array, - ) - except ExactColumnsUnavailable as exc: - return self._full_build( - time_value, - state_array, - decision="exactColumnFallback", - base_rhs=exact_base_rhs, - exact_column_unavailable=exc, - ) - candidate = self._with_exact_overrides( - self._jacobian, - time_value, - state_array, - resolved_columns=resolved_exact_columns, - ) - audit_passed, _base_rhs = self._audit_candidate( - time_value, - state_array, - candidate, - base_rhs=exact_base_rhs, - ) - if not audit_passed: - self._audit_failure_count += 1 - self._record_segment("auditFailureCount") - return self._full_build( - time_value, - state_array, - decision="auditFallback", - ) - - self._jacobian = candidate - self._secant_reuse_count += 1 - self._record_segment("secantReuseCount") - self._consecutive_reuses += 1 - self._has_secant_update = False - self._last_decision = "secantReuse" - return candidate.copy() - - def __call__( - self, - time: float, - state: Sequence[float] | np.ndarray, - ): - """Return a CSR Jacobian suitable for SciPy BDF/Radau.""" - - time_value = float(time) - if not math.isfinite(time_value): - raise ValueError("Jacobian evaluation time must be finite.") - state_array = self._state_array(state) - started_at = perf_counter() - self._jacobian_evaluation_count += 1 - self._record_segment("jacobianEvaluationCount") - try: - return self._evaluate_jacobian(time_value, state_array) - finally: - elapsed = perf_counter() - started_at - self._assembly_seconds += elapsed - self._record_segment("assemblySeconds", elapsed) - - def diagnostics(self) -> dict[str, object]: - """Return cumulative work counters and the latest reuse decision.""" - - return { - "mode": ( - "hybridSparseSecant" - if self._max_consecutive_reuses - else "optimizedSparseFiniteDifference" - ), - "fullBuildCount": self._full_build_count, - "secantReuseCount": self._secant_reuse_count, - "auditFailureCount": self._audit_failure_count, - "finiteDifferenceRhsEvaluationCount": ( - self._finite_difference_rhs_count - ), - "baseRhsEvaluationCount": self._base_rhs_count, - "jvAuditEvaluationCount": self._jv_audit_count, - "secantUpdateCount": self._secant_update_count, - "rejectedObservationCount": self._rejected_observation_count, - "segmentStartCount": self._segment_start_count, - "jacobianEvaluationCount": self._jacobian_evaluation_count, - "assemblySeconds": self._assembly_seconds, - "colorGroupCount": self._color_group_count, - "defaultColorGroupCount": self._default_color_group_count, - "originalColorGroupCount": self._original_color_group_count, - "remainingColorGroupCount": self._remaining_color_group_count, - "coloringSeed": self._coloring_seed, - "exactRowCount": len(self._exact_rows), - "exactColumnCount": len(self._exact_column_indexes), - "finiteDifferenceColumnCount": self._finite_difference_column_count, - "exactColumnOutsidePatternNonzeroCount": ( - self._exact_column_outside_pattern_nonzero_count - ), - "exactColumnBuildCount": self._exact_column_build_count, - "exactColumnFallbackCount": self._exact_column_fallback_count, - "lastExactColumnFallbackReason": ( - self._last_exact_column_fallback_reason - ), - "lastBuildFiniteDifferenceRhsEvaluationCount": ( - self._last_build_finite_difference_rhs_count - ), - "consecutiveReuseCount": self._consecutive_reuses, - "lastDecision": self._last_decision, - "lastAuditRelativeError": self._last_audit_relative_error, - "lastAuditRmsRelativeError": self._last_audit_rms_relative_error, - "lastAuditP95RelativeError": self._last_audit_p95_relative_error, - "segments": [dict(segment) for segment in self._segments], - } diff --git a/app/simulation/solvers/mechanical.py b/app/simulation/solvers/mechanical.py deleted file mode 100644 index 304f3fb..0000000 --- a/app/simulation/solvers/mechanical.py +++ /dev/null @@ -1,1037 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from math import isfinite -import os -from typing import Callable, Literal, Mapping, Sequence - -from app.simulation.components.amesim.mechanical.translational import ( - AmesimLstp00a, - AmesimMecmas21, -) -from app.simulation.core.base import DynamicComponent -from app.simulation.solvers.solver import StateTransition -from app.simulation.systems.network import SimulationNetwork - - -ConstraintMode = Literal["uninitialized", "free", "lower", "upper"] -MechanicalAbsoluteToleranceMode = Literal["legacy", "contact-aware-v1"] -DenseState = Callable[[float], Sequence[float]] - - -MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE = ( - "SIMULATION_MECHANICAL_ATOL_MODE" -) - - -def _requested_mechanical_absolute_tolerance_mode( -) -> MechanicalAbsoluteToleranceMode: - value = os.environ.get( - MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE, - "legacy", - ).strip().lower() - if value == "legacy": - return "legacy" - if value in {"contact-aware-v1", "contact_aware_v1", "contact-aware"}: - return "contact-aware-v1" - raise ValueError( - f"{MECHANICAL_ATOL_MODE_ENVIRONMENT_VARIABLE} must be " - "'legacy' or 'contact-aware-v1'." - ) - - -@dataclass(frozen=True) -class MechanicalToleranceGroupPlan: - """One rigid-coordinate group's state tolerances and proof result.""" - - components: tuple[str, ...] - contacts: tuple[str, ...] - eligible: bool - reason: str - velocity_atol: float - position_atol: float - minimum_dvel: float | None - minimum_contact_damping_length: float | None - minimum_damping_strength_ratio: float | None - minimum_force_limited_velocity_atol: float | None - - def as_dict(self) -> dict[str, object]: - return { - "components": list(self.components), - "contacts": list(self.contacts), - "eligible": self.eligible, - "reason": self.reason, - "velocityAtol": self.velocity_atol, - "positionAtol": self.position_atol, - "minimumDvel": self.minimum_dvel, - "minimumContactDampingLength": ( - self.minimum_contact_damping_length - ), - "minimumDampingStrengthRatio": ( - self.minimum_damping_strength_ratio - ), - "minimumForceLimitedVelocityAtol": ( - self.minimum_force_limited_velocity_atol - ), - } - - -@dataclass(frozen=True) -class MechanicalAbsoluteTolerancePlan: - """State-aligned absolute tolerances with auditable group proofs.""" - - mode: MechanicalAbsoluteToleranceMode - default_atol: float - legacy_mechanical_atol: float - values: tuple[float, ...] - groups: tuple[MechanicalToleranceGroupPlan, ...] - - def as_dict(self) -> dict[str, object]: - legacy_value = min( - self.default_atol, - self.legacy_mechanical_atol, - ) - relaxed_groups = tuple( - group - for group in self.groups - if group.velocity_atol > legacy_value - ) - return { - "mode": self.mode, - "defaultAtol": self.default_atol, - "legacyMechanicalAtol": self.legacy_mechanical_atol, - "stateCount": len(self.values), - "groupCount": len(self.groups), - "eligibleGroupCount": sum(group.eligible for group in self.groups), - "relaxedVelocityGroupCount": len(relaxed_groups), - "relaxedVelocityStateCount": len(relaxed_groups), - "relaxedPositionStateCount": 0, - "minimumEffectiveAtol": ( - min(self.values) if self.values else None - ), - "maximumEffectiveAtol": ( - max(self.values) if self.values else None - ), - "groups": [group.as_dict() for group in self.groups], - } - - -@dataclass -class MechanicalConstraintGroup: - """MECMAS21 inertias that share one rigid translational coordinate.""" - - components: tuple[AmesimMecmas21, ...] - mode: ConstraintMode = "uninitialized" - contact_components: tuple[AmesimLstp00a, ...] = () - - @property - def representative(self) -> AmesimMecmas21: - return self.components[0] - - @property - def total_mass(self) -> float: - return sum(component.mass for component in self.components) - - @property - def ideal_components(self) -> tuple[AmesimMecmas21, ...]: - return tuple( - component - for component in self.components - if component.uses_ideal_endstops - ) - - @property - def discrete_endstop_components(self) -> tuple[AmesimMecmas21, ...]: - return tuple( - component - for component in self.components - if int(component.stoptype) in {1, 3} - ) - - @property - def lower_bound(self) -> float | None: - components = self.discrete_endstop_components - return max((component.xmin for component in components), default=None) - - @property - def upper_bound(self) -> float | None: - components = self.discrete_endstop_components - return min((component.xmax for component in components), default=None) - - @staticmethod - def _boundary_tolerance(bound: float) -> float: - return 1.0e-12 * max(abs(bound), 1.0) - - @staticmethod - def _velocity_tolerance(velocity: float) -> float: - """Treat only floating-point-scale motion as stationary at a stop. - - Implicit solvers perturb every state while constructing a numerical - Jacobian. Around an ideal endstop those perturbations must not switch - the unilateral constraint on and off; doing so turns a zero constrained - acceleration into the full outward-force acceleration across a - machine-scale velocity delta. The tolerance is deliberately far below - MECMAS21's physical ``dvel`` threshold so real release motion is kept. - """ - - return 1.0e-12 * max(abs(velocity), 1.0) - - def reset_mode(self) -> None: - self.mode = "uninitialized" - - def release(self) -> None: - self.mode = "free" - - def synchronize_state(self) -> list[float]: - reference = self.representative - velocity_scale = max( - [abs(component.v) for component in self.components] + [1.0] - ) - position_scale = max( - [abs(component.x) for component in self.components] + [1.0] - ) - if any( - abs(component.v - reference.v) > 1.0e-10 * velocity_scale - or abs(component.x - reference.x) > 1.0e-10 * position_scale - for component in self.components[1:] - ): - names = ", ".join(component.name for component in self.components) - raise ValueError( - "Rigidly connected MECMAS21 components must have consistent " - f"initial x/v states: {names}." - ) - - lower = self.lower_bound - upper = self.upper_bound - names = ", ".join(component.name for component in self.components) - if lower is not None and upper is not None and lower > upper: - raise ValueError( - "Rigidly connected MECMAS21 components have incompatible discrete " - f"endstop limits: {names}." - ) - - position = reference.x - below_lower = ( - lower is not None - and position < lower - self._boundary_tolerance(lower) - ) - above_upper = ( - upper is not None - and position > upper + self._boundary_tolerance(upper) - ) - if below_lower or above_upper: - raise ValueError( - f"Initial MECMAS21 position {position:g} is outside the discrete " - f"endstop limits for: {names}." - ) - if lower is not None and position < lower: - position = lower - if upper is not None and position > upper: - position = upper - - state = [reference.v, position] - self.set_state_vector(state) - return state - - def set_state_vector(self, values: Sequence[float]) -> None: - state = [float(value) for value in values] - for component in self.components: - component.set_state_vector(state) - - def total_unconstrained_force(self) -> float: - return sum( - component.mass * component.unconstrained_acceleration() - for component in self.components - ) - - def _static_endstop_side(self, total_force: float) -> str | None: - position = self.representative.x - velocity = self.representative.v - velocity_tolerance = self._velocity_tolerance(velocity) - lower = self.lower_bound - upper = self.upper_bound - # MECMAS21's dvel is the friction stick threshold. Its discrete - # endstops release by motion direction; velocity away from a stop is free. - if ( - lower is not None - and position <= lower + self._boundary_tolerance(lower) - and velocity <= velocity_tolerance - and total_force <= 0.0 - ): - return "lower" - if ( - upper is not None - and position >= upper - self._boundary_tolerance(upper) - and velocity >= -velocity_tolerance - and total_force >= 0.0 - ): - return "upper" - return None - - def lock(self, side: Literal["lower", "upper"]) -> None: - self.mode = side - - def impact_velocity( - self, - side: Literal["lower", "upper"], - incoming_velocity: float, - ) -> float: - """Return the post-impact velocity for the active group boundary.""" - - bound = self.lower_bound if side == "lower" else self.upper_bound - if bound is None: - return float(incoming_velocity) - parameter_name = "xmin" if side == "lower" else "xmax" - active_components = tuple( - component - for component in self.discrete_endstop_components - if abs(float(getattr(component, parameter_name)) - bound) - <= self._boundary_tolerance(bound) - ) - if any(int(component.stoptype) == 1 for component in active_components): - return 0.0 - - restitution_components = tuple( - component - for component in active_components - if int(component.stoptype) == 3 - ) - speed = abs(float(incoming_velocity)) - threshold = max( - (component.restdvel for component in restitution_components), - default=0.0, - ) - if speed <= threshold: - return 0.0 - - # A rigid group cannot satisfy two different simultaneous rebounds; - # use the most dissipative active stop after plastic priority. - restitution = min( - (component.restcoeff for component in restitution_components), - default=0.0, - ) - outgoing_speed = restitution * speed - return outgoing_speed if side == "lower" else -outgoing_speed - - def update_acceleration(self) -> float: - """Resolve the current ideal constraint without committing event mode. - - ODE solvers may evaluate rejected or out-of-order trial states. The - derivative calculation therefore cannot change ``mode``; only an - accepted state transition may commit a discrete impact mode. - """ - - total_force = self.total_unconstrained_force() - if self._static_endstop_side(total_force) is not None: - for component in self.components: - component.set_constraint_motion( - 0.0, - velocity=0.0, - ) - return 0.0 - - acceleration = total_force / self.total_mass - for component in self.components: - component.set_constraint_motion(acceleration) - return acceleration - - -StateEntry = DynamicComponent | MechanicalConstraintGroup - - -class MechanicalStateReducer: - """V1 rigid-inertia reduction and event-driven discrete-endstop handling. - - Rigid mechanical effort relations are causalized into one ``[v, x]`` ODE - coordinate per connected mass group. ``MECMAS21 stoptype=1`` applies a - plastic impact, while ``stoptype=3`` applies its restitution coefficient - above the configured velocity threshold. - """ - - def __init__( - self, - network: SimulationNetwork, - dynamic_components: list[DynamicComponent], - ) -> None: - self.network = network - self.dynamic_components = dynamic_components - self.groups = self._build_groups() - self._group_by_component = { - component.name: group - for group in self.groups - for component in group.components - } - self.state_entries = self._build_state_entries() - self._group_state_offsets = self._build_group_state_offsets() - - @staticmethod - def _port_key( - variable: str, - expected_variable: str, - ) -> tuple[str, str] | None: - try: - component, port, variable_name = variable.rsplit(".", 2) - except ValueError: - return None - if variable_name != expected_variable: - return None - return component, port - - def _build_groups(self) -> tuple[MechanicalConstraintGroup, ...]: - mechanical_ports = { - (component.name, definition.name) - for component in self.network.components.values() - for definition in component.active_port_definitions - if definition.kind == "physical" and definition.domain == "mechanical" - } - parents = { - variable: {key: key for key in mechanical_ports} - for variable in ("x", "v") - } - - def find(variable: str, key: tuple[str, str]) -> tuple[str, str]: - parent = parents[variable] - root = key - while parent[root] != root: - root = parent[root] - while parent[key] != key: - next_key = parent[key] - parent[key] = root - key = next_key - return root - - def union( - variable: str, - first: tuple[str, str], - second: tuple[str, str], - ) -> None: - first_root = find(variable, first) - second_root = find(variable, second) - if first_root != second_root: - parents[variable][second_root] = first_root - - for connection in self.network.connections: - first = connection.endpoint_a.key - second = connection.endpoint_b.key - if first in mechanical_ports and second in mechanical_ports: - for variable in ("x", "v"): - union(variable, first, second) - - for component in self.network.components.values(): - for equation in component.pressure_flow_equation_residuals(): - if equation.relation != "equal" or equation.role != "effort": - continue - for variable in ("x", "v"): - endpoints = [ - endpoint - for equation_variable in equation.variables - if ( - (endpoint := self._port_key(equation_variable, variable)) - in mechanical_ports - ) - ] - for endpoint in endpoints[1:]: - union(variable, endpoints[0], endpoint) - - masses = [ - component - for component in self.dynamic_components - if isinstance(component, AmesimMecmas21) - ] - for component in masses: - ports = [ - (component.name, definition.name) - for definition in component.active_port_definitions - if definition.kind == "physical" - and definition.domain == "mechanical" - ] - for port in ports[1:]: - for variable in ("x", "v"): - union(variable, ports[0], port) - - masses_by_roots: dict[ - tuple[tuple[str, str], tuple[str, str]], - list[AmesimMecmas21], - ] = {} - for component in masses: - first_port = next( - (component.name, definition.name) - for definition in component.active_port_definitions - if definition.kind == "physical" - and definition.domain == "mechanical" - ) - roots = (find("x", first_port), find("v", first_port)) - masses_by_roots.setdefault(roots, []).append(component) - - contacts_by_roots: dict[ - tuple[tuple[str, str], tuple[str, str]], - dict[str, AmesimLstp00a], - ] = {} - for component in self.network.components.values(): - if not isinstance(component, AmesimLstp00a): - continue - contact_roots = tuple( - ( - find("x", (component.name, definition.name)), - find("v", (component.name, definition.name)), - ) - for definition in component.active_port_definitions - if ( - definition.kind == "physical" - and definition.domain == "mechanical" - ) - ) - if ( - len({roots[0] for roots in contact_roots}) < 2 - or len({roots[1] for roots in contact_roots}) < 2 - ): - # A compliant contact whose two ports resolve to the same - # rigid coordinate cannot damp that coordinate. Treating the - # self-loop as proof would relax an unrelated velocity state. - continue - for definition in component.active_port_definitions: - if ( - definition.kind != "physical" - or definition.domain != "mechanical" - ): - continue - endpoint = (component.name, definition.name) - roots = (find("x", endpoint), find("v", endpoint)) - contacts_by_roots.setdefault(roots, {})[ - component.name - ] = component - - return tuple( - MechanicalConstraintGroup( - 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, ...]: - entries: list[StateEntry] = [] - for component in self.dynamic_components: - group = self._group_by_component.get(component.name) - if group is None: - entries.append(component) - elif group.representative is component: - entries.append(group) - return tuple(entries) - - def _build_group_state_offsets(self) -> dict[int, int]: - offsets: dict[int, int] = {} - cursor = 0 - for entry in self.state_entries: - if isinstance(entry, MechanicalConstraintGroup): - offsets[id(entry)] = cursor - cursor += 2 - else: - cursor += entry.state_size - return offsets - - @property - def has_state_events(self) -> bool: - return any(group.discrete_endstop_components for group in self.groups) - - def absolute_tolerance_plan( - self, - default: float, - *, - mechanical: float = 1.0e-12, - mode: MechanicalAbsoluteToleranceMode | None = None, - ) -> MechanicalAbsoluteTolerancePlan: - """Compile state tolerances without weakening non-smooth coordinates. - - A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued - endstop position across the much smaller unilateral boundary band while - constructing finite-difference Jacobians. Positions and ideal endstop - states therefore retain the legacy machine-scale tolerance. - - Strongly damped, compliant LSTP contact can instead drive a *free* - velocity close to zero for hundreds of accepted steps. Only a - compile-proven smooth-contact group may use the bounded velocity floor; - the contact position coordinate remains unchanged. - """ - default_atol = float(default) - mechanical_atol = float(mechanical) - if not isfinite(default_atol) or default_atol <= 0.0: - raise ValueError( - "default absolute tolerance must be finite and positive." - ) - if not isfinite(mechanical_atol) or mechanical_atol <= 0.0: - raise ValueError( - "mechanical absolute tolerance must be finite and positive." - ) - selected_mode = mode or _requested_mechanical_absolute_tolerance_mode() - if selected_mode not in {"legacy", "contact-aware-v1"}: - raise ValueError( - "mechanical absolute tolerance mode must be 'legacy' or " - "'contact-aware-v1'." - ) - - legacy_atol = min(default_atol, mechanical_atol) - values: list[float] = [] - group_plans: list[MechanicalToleranceGroupPlan] = [] - for entry in self.state_entries: - if not isinstance(entry, MechanicalConstraintGroup): - values.extend([default_atol] * entry.state_size) - continue - - components = entry.components - contacts = entry.contact_components - positive_dvel = tuple( - float(component.dvel) - for component in components - if isfinite(float(component.dvel)) - and float(component.dvel) > 0.0 - ) - positive_pdis = tuple( - float(contact.Pdis) - for contact in contacts - if isfinite(float(contact.Pdis)) - and float(contact.Pdis) > 0.0 - ) - minimum_dvel = min(positive_dvel, default=None) - minimum_pdis = min(positive_pdis, default=None) - contact_scale_valid = True - contact_force_velocity_limits_list: list[float] = [] - damping_strength_ratios_list: list[float] = [] - if selected_mode == "contact-aware-v1" and minimum_dvel is not None: - for contact in contacts: - stiffness = float(contact.kcont) - damping_length = float(contact.Pdis) - damping = float(contact.rcont) - if not ( - isfinite(stiffness) - and stiffness > 0.0 - and isfinite(damping_length) - and damping_length > 0.0 - and isfinite(damping) - and damping > 0.0 - ): - contact_scale_valid = False - continue - elastic_force_scale = stiffness * damping_length - damping_force_scale = damping * minimum_dvel - if not ( - isfinite(elastic_force_scale) - and elastic_force_scale > 0.0 - and isfinite(damping_force_scale) - and damping_force_scale > 0.0 - ): - contact_scale_valid = False - continue - force_velocity_limit = ( - 1.0e-3 * elastic_force_scale / damping - ) - damping_strength_ratio = ( - damping_force_scale / elastic_force_scale - ) - if not ( - isfinite(force_velocity_limit) - and force_velocity_limit > 0.0 - and isfinite(damping_strength_ratio) - and damping_strength_ratio > 0.0 - ): - contact_scale_valid = False - continue - contact_force_velocity_limits_list.append( - force_velocity_limit - ) - damping_strength_ratios_list.append( - damping_strength_ratio - ) - contact_force_velocity_limits = tuple( - contact_force_velocity_limits_list - ) - minimum_force_velocity_atol = min( - contact_force_velocity_limits, - default=None, - ) - damping_strength_ratios = tuple( - damping_strength_ratios_list - ) - minimum_damping_strength_ratio = min( - damping_strength_ratios, - default=None, - ) - - if selected_mode == "legacy": - eligible = False - reason = "legacyMode" - elif entry.discrete_endstop_components: - eligible = False - reason = "discreteEndstop" - elif any(int(component.stoptype) != 4 for component in components): - eligible = False - reason = "unsupportedStopType" - elif any( - component.use_friction and float(component.fcoul) != 0.0 - for component in components - ): - eligible = False - reason = "dryFriction" - elif not contacts: - eligible = False - reason = "noFlexibleContact" - elif any( - not isfinite(float(contact.Pdis)) - or float(contact.Pdis) <= 0.0 - for contact in contacts - ): - eligible = False - reason = "nonSmoothContactDampingLength" - elif any( - not isfinite(float(contact.rcont)) - or float(contact.rcont) <= 0.0 - for contact in contacts - ): - eligible = False - reason = "undampedContact" - elif any( - not isfinite(float(contact.kcont)) - or float(contact.kcont) <= 0.0 - for contact in contacts - ): - eligible = False - reason = "invalidContactStiffness" - elif not contact_scale_valid: - eligible = False - reason = "invalidContactScale" - elif any( - int(contact.discContactOption) != 1 - for contact in contacts - ): - eligible = False - reason = "clampedContactForce" - elif len(positive_dvel) != len(components): - eligible = False - reason = "invalidVelocityScale" - elif ( - len(damping_strength_ratios) != len(contacts) - or minimum_damping_strength_ratio is None - or minimum_damping_strength_ratio < 1.0 - ): - eligible = False - reason = "weakContactDamping" - else: - eligible = True - reason = "eligibleFlexibleContact" - - velocity_atol = legacy_atol - if eligible: - assert minimum_dvel is not None - assert minimum_force_velocity_atol is not None - velocity_atol = min( - default_atol, - max( - mechanical_atol, - min( - 1.0e-9, - 1.0e-3 * minimum_dvel, - minimum_force_velocity_atol, - ), - ), - ) - position_atol = legacy_atol - values.extend((velocity_atol, position_atol)) - group_plans.append( - MechanicalToleranceGroupPlan( - components=tuple( - component.name for component in components - ), - contacts=tuple(contact.name for contact in contacts), - eligible=eligible, - reason=reason, - velocity_atol=velocity_atol, - position_atol=position_atol, - minimum_dvel=minimum_dvel, - minimum_contact_damping_length=minimum_pdis, - minimum_damping_strength_ratio=( - minimum_damping_strength_ratio - ), - minimum_force_limited_velocity_atol=( - minimum_force_velocity_atol - ), - ) - ) - - return MechanicalAbsoluteTolerancePlan( - mode=selected_mode, - default_atol=default_atol, - legacy_mechanical_atol=mechanical_atol, - values=tuple(values), - groups=tuple(group_plans), - ) - - def absolute_tolerances( - self, - default: float, - *, - mechanical: float = 1.0e-12, - mode: MechanicalAbsoluteToleranceMode | None = None, - ) -> list[float]: - """Return state-aligned values from the auditable tolerance plan.""" - - return list( - self.absolute_tolerance_plan( - default, - mechanical=mechanical, - mode=mode, - ).values - ) - - def reset_constraint_modes(self) -> None: - for group in self.groups: - group.reset_mode() - - def initial_state_vector(self) -> list[float]: - self.reset_constraint_modes() - values: list[float] = [] - for entry in self.state_entries: - if isinstance(entry, MechanicalConstraintGroup): - values.extend(entry.synchronize_state()) - else: - values.extend(entry.get_state_vector()) - return values - - def apply_state_vector(self, values: list[float]) -> None: - cursor = 0 - for entry in self.state_entries: - state_size = ( - 2 if isinstance(entry, MechanicalConstraintGroup) else entry.state_size - ) - next_cursor = cursor + state_size - state = values[cursor:next_cursor] - if isinstance(entry, MechanicalConstraintGroup): - entry.set_state_vector(state) - else: - entry.set_state_vector(state) - cursor = next_cursor - if cursor != len(values): - raise ValueError("State vector length does not match reduced dynamic components.") - - def update_constraint_accelerations(self) -> None: - for group in self.groups: - group.update_acceleration() - - def state_derivatives( - self, - connected_h: Mapping[str, Mapping[str, float]], - ) -> list[float]: - derivatives: list[float] = [] - for entry in self.state_entries: - component = ( - entry.representative - if isinstance(entry, MechanicalConstraintGroup) - else entry - ) - derivatives.extend( - component.state_derivative_from_ports(connected_h[component.name]) - ) - return derivatives - - @staticmethod - def _locate_crossing( - dense_state: DenseState, - state_index: int, - bound: float, - side: Literal["lower", "upper"], - start_time: float, - end_time: float, - ) -> float: - lower_time = float(start_time) - upper_time = float(end_time) - for _iteration in range(60): - middle_time = 0.5 * (lower_time + upper_time) - position = float(dense_state(middle_time)[state_index]) - crossed = position <= bound if side == "lower" else position >= bound - if crossed: - upper_time = middle_time - else: - lower_time = middle_time - return upper_time - - @staticmethod - def _locate_turnaround( - dense_state: DenseState, - velocity_index: int, - side: Literal["lower", "upper"], - start_time: float, - end_time: float, - ) -> float: - """Locate the velocity reversal preceding a same-step re-impact.""" - - lower_time = float(start_time) - upper_time = float(end_time) - for _iteration in range(60): - middle_time = 0.5 * (lower_time + upper_time) - velocity = float(dense_state(middle_time)[velocity_index]) - turned = velocity <= 0.0 if side == "lower" else velocity >= 0.0 - if turned: - upper_time = middle_time - else: - lower_time = middle_time - return upper_time - - def state_transition( - self, - previous_time: float, - previous_state: list[float], - current_time: float, - current_state: list[float], - dense_state: DenseState, - ) -> StateTransition | None: - """Return the earliest discrete-endstop impact in one accepted ODE step.""" - - candidates: list[ - tuple[float, MechanicalConstraintGroup, Literal["lower", "upper"], float] - ] = [] - for group in self.groups: - if not group.discrete_endstop_components: - continue - velocity_index = self._group_state_offsets[id(group)] - position_index = velocity_index + 1 - previous_velocity = float(previous_state[velocity_index]) - current_velocity = float(current_state[velocity_index]) - previous_velocity_tolerance = group._velocity_tolerance(previous_velocity) - current_velocity_tolerance = group._velocity_tolerance(current_velocity) - previous_position = float(previous_state[position_index]) - current_position = float(current_state[position_index]) - lower = group.lower_bound - upper = group.upper_bound - if ( - lower is not None - and previous_position <= lower + group._boundary_tolerance(lower) - and previous_velocity < -previous_velocity_tolerance - ): - candidates.append((previous_time, group, "lower", lower)) - elif ( - lower is not None - and previous_position > lower + group._boundary_tolerance(lower) - and current_position <= lower - ): - candidates.append( - ( - self._locate_crossing( - dense_state, - position_index, - lower, - "lower", - previous_time, - current_time, - ), - group, - "lower", - lower, - ) - ) - elif ( - lower is not None - and previous_position <= lower - and previous_velocity > previous_velocity_tolerance - and current_velocity < -current_velocity_tolerance - and current_position <= lower - ): - turnaround_time = self._locate_turnaround( - dense_state, - velocity_index, - "lower", - previous_time, - current_time, - ) - candidates.append( - ( - self._locate_crossing( - dense_state, - position_index, - lower, - "lower", - turnaround_time, - current_time, - ), - group, - "lower", - lower, - ) - ) - if ( - upper is not None - and previous_position >= upper - group._boundary_tolerance(upper) - and previous_velocity > previous_velocity_tolerance - ): - candidates.append((previous_time, group, "upper", upper)) - elif ( - upper is not None - and previous_position < upper - group._boundary_tolerance(upper) - and current_position >= upper - ): - candidates.append( - ( - self._locate_crossing( - dense_state, - position_index, - upper, - "upper", - previous_time, - current_time, - ), - group, - "upper", - upper, - ) - ) - elif ( - upper is not None - and previous_position >= upper - and previous_velocity < -previous_velocity_tolerance - and current_velocity > current_velocity_tolerance - and current_position >= upper - ): - turnaround_time = self._locate_turnaround( - dense_state, - velocity_index, - "upper", - previous_time, - current_time, - ) - candidates.append( - ( - self._locate_crossing( - dense_state, - position_index, - upper, - "upper", - turnaround_time, - current_time, - ), - group, - "upper", - upper, - ) - ) - - if not candidates: - return None - - event_time = min(candidate[0] for candidate in candidates) - event_state = [float(value) for value in dense_state(event_time)] - simultaneous_tolerance = 1.0e-12 * max(abs(event_time), 1.0) - for candidate_time, group, side, bound in candidates: - if abs(candidate_time - event_time) > simultaneous_tolerance: - continue - velocity_index = self._group_state_offsets[id(group)] - event_state[velocity_index] = group.impact_velocity( - side, - event_state[velocity_index], - ) - event_state[velocity_index + 1] = bound - if event_state[velocity_index] == 0.0: - group.lock(side) - else: - group.release() - - return StateTransition(time=event_time, state=event_state) diff --git a/app/simulation/solvers/pneumatic_storage.py b/app/simulation/solvers/pneumatic_storage.py deleted file mode 100644 index 15968da..0000000 --- a/app/simulation/solvers/pneumatic_storage.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Iterable, Sequence - -from app.simulation.components.amesim.flow.pipes import ( - AmesimPnl0001, - AmesimPnl0002, - AmesimPnl0003, -) -from app.simulation.solvers.mechanical import MechanicalConstraintGroup -from app.simulation.systems.network import Endpoint, SimulationNetwork - -if TYPE_CHECKING: - from app.simulation.core.base import DynamicComponent - from app.simulation.solvers.mechanical import MechanicalStateReducer - - -@dataclass(frozen=True) -class PneumaticStoragePartition: - """One fixed-volume ``[mass, internal energy]`` pressure-state partition.""" - - component: DynamicComponent - state_offset: int - volume: float - - @property - def key(self) -> tuple[str, int]: - return self.component.name, self.state_offset - - -@dataclass(frozen=True) -class IdealPneumaticStorageGroup: - partitions: tuple[PneumaticStoragePartition, ...] - - @property - def names(self) -> tuple[str, ...]: - return tuple(partition.component.name for partition in self.partitions) - - -def pneumatic_storage_partition( - network: SimulationNetwork, - endpoint: Endpoint, -) -> PneumaticStoragePartition | None: - """Map an AMESim pressure-state port to its fixed gas-volume state slice. - - PNL0001 exposes its C side at ``port_2``. PNL0003 exposes one compliance - at each end. PNL0002 has resistances at both external ports, so its center - compliance is intentionally not returned here. - """ - - component = network.components[endpoint.component] - if isinstance(component, AmesimPnl0003): - if endpoint.port == "port_1": - return PneumaticStoragePartition( - component=component, - state_offset=0, - volume=component.compliance_volume, - ) - if endpoint.port == "port_2": - return PneumaticStoragePartition( - component=component, - state_offset=2, - volume=component.compliance_volume, - ) - return None - if isinstance(component, AmesimPnl0001) and not isinstance( - component, AmesimPnl0002 - ): - if endpoint.port == "port_2": - return PneumaticStoragePartition( - component=component, - state_offset=0, - volume=component.volume, - ) - return None - - -def ideal_storage_group_is_reducible( - network: SimulationNetwork, - storage_endpoints: Iterable[Endpoint], -) -> bool: - partitions = [ - pneumatic_storage_partition(network, endpoint) - for endpoint in storage_endpoints - ] - if not partitions or any(partition is None for partition in partitions): - return False - unique = {partition.key: partition for partition in partitions if partition} - if len(unique) < 2: - return False - media = {id(partition.component.medium) for partition in unique.values()} - return len(media) == 1 and all(partition.volume > 0.0 for partition in unique.values()) - - -def _pressure_storage_endpoint_groups( - network: SimulationNetwork, -) -> tuple[tuple[Endpoint, ...], ...]: - pneumatic_endpoints = { - Endpoint(component.name, definition.name) - for component in network.components.values() - for definition in component.active_port_definitions - if definition.kind == "physical" and definition.domain == "pneumatic" - } - parent = {endpoint: endpoint for endpoint in pneumatic_endpoints} - - def find(endpoint: Endpoint) -> Endpoint: - root = endpoint - while parent[root] != root: - root = parent[root] - while parent[endpoint] != endpoint: - next_endpoint = parent[endpoint] - parent[endpoint] = root - endpoint = next_endpoint - return root - - def union(first: Endpoint, second: Endpoint) -> None: - first_root = find(first) - second_root = find(second) - if first_root != second_root: - parent[second_root] = first_root - - for connection in network.connections: - first, second = connection.endpoints - if first in pneumatic_endpoints and second in pneumatic_endpoints: - union(first, second) - - storage_endpoints: set[Endpoint] = set() - for component in network.components.values(): - for equation in component.pressure_flow_equation_residuals(): - pressure_endpoints = [ - Endpoint(component.name, variable.rsplit(".", 2)[1]) - for variable in equation.variables - if variable.startswith(f"{component.name}.") and variable.endswith(".p") - ] - if equation.relation == "equal": - for endpoint in pressure_endpoints[1:]: - union(pressure_endpoints[0], endpoint) - elif equation.relation == "state": - storage_endpoints.update(pressure_endpoints) - - by_root: dict[Endpoint, list[Endpoint]] = {} - for endpoint in storage_endpoints: - by_root.setdefault(find(endpoint), []).append(endpoint) - return tuple(tuple(endpoints) for endpoints in by_root.values()) - - -class IdealPneumaticStorageReducer: - """Project supported ideal C-C connections onto one thermodynamic state. - - AMESim permits compatible pipe compliances to share an ideal pneumatic - junction. The public ODE solver keeps the original result states but - projects their mass and energy densities together and distributes the - group's total derivative by physical volume. This removes the redundant - pressure constraint without adding a fictitious resistance. - """ - - def __init__( - self, - network: SimulationNetwork, - mechanical_state_reducer: MechanicalStateReducer, - ) -> None: - self.network = network - self.mechanical_state_reducer = mechanical_state_reducer - self.groups = self._build_groups() - self._component_offsets = self._build_component_offsets() - - def _build_groups(self) -> tuple[IdealPneumaticStorageGroup, ...]: - groups: list[IdealPneumaticStorageGroup] = [] - for endpoints in _pressure_storage_endpoint_groups(self.network): - partitions = [ - pneumatic_storage_partition(self.network, endpoint) - for endpoint in endpoints - ] - unique = { - partition.key: partition - for partition in partitions - if partition is not None - } - if len(unique) > 1 and len(unique) == len( - {endpoint.component for endpoint in endpoints} - ): - group = IdealPneumaticStorageGroup(tuple(unique.values())) - if ideal_storage_group_is_reducible(self.network, endpoints): - groups.append(group) - return tuple(groups) - - def _build_component_offsets(self) -> dict[str, int]: - offsets: dict[str, int] = {} - cursor = 0 - for entry in self.mechanical_state_reducer.state_entries: - if isinstance(entry, MechanicalConstraintGroup): - cursor += 2 - else: - offsets[entry.name] = cursor - cursor += entry.state_size - return offsets - - def _global_offset(self, partition: PneumaticStoragePartition) -> int: - return self._component_offsets[partition.component.name] + partition.state_offset - - def synchronize_state_vector( - self, - values: Sequence[float], - *, - validate: bool = False, - ) -> list[float]: - projected = [float(value) for value in values] - for group in self.groups: - volumes = [partition.volume for partition in group.partitions] - offsets = [self._global_offset(partition) for partition in group.partitions] - mass_densities = [ - projected[offset] / volume - for offset, volume in zip(offsets, volumes) - ] - energy_densities = [ - projected[offset + 1] / volume - for offset, volume in zip(offsets, volumes) - ] - if validate: - mass_scale = max([abs(value) for value in mass_densities] + [1.0]) - energy_scale = max([abs(value) for value in energy_densities] + [1.0]) - if ( - max(mass_densities) - min(mass_densities) > 1.0e-9 * mass_scale - or max(energy_densities) - min(energy_densities) - > 1.0e-9 * energy_scale - ): - raise ValueError( - "Ideally coupled AMESim pipe compliances require consistent " - "initial pressure and temperature: " + ", ".join(group.names) - ) - total_volume = sum(volumes) - mass_density = sum(projected[offset] for offset in offsets) / total_volume - energy_density = ( - sum(projected[offset + 1] for offset in offsets) / total_volume - ) - for offset, volume in zip(offsets, volumes): - projected[offset] = mass_density * volume - projected[offset + 1] = energy_density * volume - return projected - - def coupled_derivatives(self, values: Sequence[float]) -> list[float]: - derivatives = [float(value) for value in values] - for group in self.groups: - volumes = [partition.volume for partition in group.partitions] - offsets = [self._global_offset(partition) for partition in group.partitions] - total_volume = sum(volumes) - total_mass_derivative = sum(derivatives[offset] for offset in offsets) - total_energy_derivative = sum( - derivatives[offset + 1] for offset in offsets - ) - for offset, volume in zip(offsets, volumes): - fraction = volume / total_volume - derivatives[offset] = total_mass_derivative * fraction - derivatives[offset + 1] = total_energy_derivative * fraction - return derivatives diff --git a/app/simulation/solvers/pneumatic_volume.py b/app/simulation/solvers/pneumatic_volume.py deleted file mode 100644 index c48f7c7..0000000 --- a/app/simulation/solvers/pneumatic_volume.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from math import isfinite - -from app.simulation.core.base import Component -from app.simulation.core.ports import PortState -from app.simulation.performance import profile_phase -from app.simulation.systems.network import Endpoint, SimulationNetwork - - -@dataclass(frozen=True) -class PneumaticVolumeDiagnostics: - propagated: int - output_ports: tuple[str, ...] - - def as_dict(self) -> dict[str, object]: - return { - "propagated": self.propagated, - "outputPorts": list(self.output_ports), - } - - -@dataclass(frozen=True) -class _PneumaticVolumeConnectionBinding: - connected_endpoint: Endpoint - connected_port: PortState - - -class PneumaticVolumeResolver: - """Propagate AMESim pneumatic external-volume connector variables.""" - - def __init__(self, network: SimulationNetwork) -> None: - self.network = network - self._pneumatic_ports = tuple( - component.get_port(definition.name) - for component in network.components.values() - for definition in component.active_port_definitions - if definition.kind == "physical" and definition.domain == "pneumatic" - ) - self._output_components = tuple( - component - for component in network.components.values() - if type(component).pneumatic_volume_outputs - is not Component.pneumatic_volume_outputs - ) - self._connected_endpoint = self._build_connection_map() - self.last_diagnostics: PneumaticVolumeDiagnostics | None = None - - def _build_connection_map( - self, - ) -> dict[Endpoint, _PneumaticVolumeConnectionBinding]: - result: dict[Endpoint, _PneumaticVolumeConnectionBinding] = {} - for connection in self.network.connections: - if connection.kind != "physical" or connection.domain != "pneumatic": - continue - first, second = connection.endpoints - result[first] = _PneumaticVolumeConnectionBinding( - connected_endpoint=second, - connected_port=self.network.components[second.component].get_port( - second.port - ), - ) - result[second] = _PneumaticVolumeConnectionBinding( - connected_endpoint=first, - connected_port=self.network.components[first.component].get_port( - first.port - ), - ) - return result - - @profile_phase("simulation.pneumatic_volume", minimum_mode="audit") - def solve(self) -> PneumaticVolumeDiagnostics: - for port in self._pneumatic_ports: - port.volume = 0.0 - port.volume_flow = 0.0 - - outputs: dict[Endpoint, tuple[float, float]] = {} - for component in self._output_components: - for port_name, raw_values in component.pneumatic_volume_outputs().items(): - port = component.get_port(port_name) - definition = port.definition - if ( - definition is None - or definition.kind != "physical" - or definition.domain != "pneumatic" - ): - raise ValueError( - f"Component {component.name} declares pneumatic volume output " - f"on non-pneumatic port {port_name}." - ) - volume, volume_flow = (float(raw_values[0]), float(raw_values[1])) - if not isfinite(volume) or not isfinite(volume_flow): - raise ValueError( - f"Component {component.name}.{port_name} produced a non-finite " - "pneumatic volume value." - ) - endpoint = Endpoint(component.name, port_name) - outputs[endpoint] = (volume, volume_flow) - port.volume = volume - port.volume_flow = volume_flow - - propagated = 0 - for endpoint, values in outputs.items(): - binding = self._connected_endpoint.get(endpoint) - if binding is None: - continue - if binding.connected_endpoint in outputs: - raise ValueError( - "A pneumatic connection cannot contain two external-volume " - f"sources: {endpoint} and {binding.connected_endpoint}." - ) - binding.connected_port.volume, binding.connected_port.volume_flow = values - propagated += 1 - - diagnostics = PneumaticVolumeDiagnostics( - propagated=propagated, - output_ports=tuple(sorted(str(endpoint) for endpoint in outputs)), - ) - self.last_diagnostics = diagnostics - return diagnostics diff --git a/app/simulation/solvers/signal.py b/app/simulation/solvers/signal.py deleted file mode 100644 index 10515b1..0000000 --- a/app/simulation/solvers/signal.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from math import isfinite -from typing import Callable, Protocol - -from app.simulation.core.base import Component -from app.simulation.core.ports import PortState -from app.simulation.performance import profile_phase -from app.simulation.systems.network import Endpoint, SimulationNetwork - - -class SignalOutputComponent(Protocol): - name: str - - def signal_output_values(self, time: float) -> dict[str, float]: - ... - - -class SignalEventSource(Protocol): - """Optional contract for signal sources with known time discontinuities.""" - - name: str - - def signal_event_times( - self, - start_time: float, - stop_time: float, - ) -> tuple[float, ...]: - """Return event times strictly inside ``(start_time, stop_time)``.""" - - ... - - -@dataclass(frozen=True) -class SignalSolveDiagnostics: - propagated: int - - def as_dict(self) -> dict[str, object]: - return {"propagated": self.propagated} - - -@dataclass(frozen=True) -class _SignalOutputBinding: - component: Component - evaluate: Callable[[float], dict[str, float]] - - -@dataclass(frozen=True) -class _SignalConnectionBinding: - source: PortState - target: PortState - - -class SignalResolver: - """Propagate scalar signal connections from output ports to input ports.""" - - def __init__(self, network: SimulationNetwork) -> None: - self.network = network - self._output_bindings = tuple( - _SignalOutputBinding(component=component, evaluate=evaluate) - for component in network.components.values() - if (evaluate := getattr(component, "signal_output_values", None)) is not None - ) - self._event_sources = tuple( - (component.name, source_event_times) - for component in network.components.values() - if ( - source_event_times := getattr( - component, - "signal_event_times", - None, - ) - ) - is not None - ) - self._connections = tuple( - self._connection_binding(connection.endpoints) - for connection in network.connections - if connection.kind == "signal" - ) - self.last_diagnostics: SignalSolveDiagnostics | None = None - - @profile_phase("simulation.signal", minimum_mode="audit") - def solve(self, time: float) -> SignalSolveDiagnostics: - for binding in self._output_bindings: - for port_name, value in binding.evaluate(time).items(): - binding.component.get_port(port_name).signal = float(value) - - propagated = 0 - for binding in self._connections: - binding.target.signal = binding.source.signal - propagated += 1 - - diagnostics = SignalSolveDiagnostics(propagated=propagated) - self.last_diagnostics = diagnostics - return diagnostics - - def event_times(self, start_time: float, stop_time: float) -> tuple[float, ...]: - """Collect optional source events that can be used as integration splits. - - Event discovery is deliberately duck typed so existing signal-output - components remain valid without implementing ``signal_event_times``. - """ - - start = float(start_time) - stop = float(stop_time) - if not isfinite(start) or not isfinite(stop): - raise ValueError("Signal event interval must be finite.") - if stop < start: - raise ValueError("Signal event interval stop must not precede start.") - if stop == start: - return () - - events: set[float] = set() - for component_name, source_event_times in self._event_sources: - for raw_time in source_event_times(start, stop): - event_time = float(raw_time) - if not isfinite(event_time): - raise ValueError( - f"Signal event time from component '{component_name}' must be finite." - ) - if start < event_time < stop: - events.add(event_time) - return tuple(sorted(events)) - - def _source_target(self, endpoints: tuple[Endpoint, Endpoint]) -> tuple[Endpoint, Endpoint]: - first, second = endpoints - first_port = self.network.components[first.component].get_port(first.port) - second_port = self.network.components[second.component].get_port(second.port) - if first_port.definition is not None and first_port.definition.nominal_role == "output": - return first, second - if second_port.definition is not None and second_port.definition.nominal_role == "output": - return second, first - raise ValueError("Signal connection must contain one output endpoint.") - - def _connection_binding( - self, - endpoints: tuple[Endpoint, Endpoint], - ) -> _SignalConnectionBinding: - source, target = self._source_target(endpoints) - return _SignalConnectionBinding( - source=self.network.components[source.component].get_port(source.port), - target=self.network.components[target.component].get_port(target.port), - ) diff --git a/app/simulation/solvers/solver.py b/app/simulation/solvers/solver.py deleted file mode 100644 index a885bd6..0000000 --- a/app/simulation/solvers/solver.py +++ /dev/null @@ -1,1673 +0,0 @@ -from __future__ import annotations - -from app.simulation.core.errors import RecoverableTrialStateError -from app.simulation.performance import profile_phase - -import math -from dataclasses import dataclass -from typing import Callable, Literal, Sequence - - -CancellationCheck = Callable[[], bool] -AcceptedStepCallback = Callable[[float], None] -IntegrationStatus = Literal["completed", "cancelled", "failed"] -DenseState = Callable[[float], list[float]] -JacobianCallable = Callable[[float, object], object] - - -@dataclass(frozen=True) -class SolverActivitySnapshot: - """Low-cost, additive view of work inside an integration task. - - ``accepted_time`` deliberately changes only after an accepted solver step. - Trial evaluations may continue to advance ``activity_sequence`` and - ``current_trial_time`` while that public progress value stays fixed. - """ - - activity_sequence: int - activity_kind: str - current_trial_time: float | None - rhs_call_count: int - accepted_step_sequence: int - accepted_time: float | None - solver_step_sequence: int - jacobian_evaluation_count: int - thermofluid_closure_count: int - - def as_dict(self) -> dict[str, object]: - return { - "activitySequence": self.activity_sequence, - "activityKind": self.activity_kind, - "currentTrialTime": self.current_trial_time, - "rhsCallCount": self.rhs_call_count, - "acceptedStepSequence": self.accepted_step_sequence, - "acceptedTime": self.accepted_time, - "solverStepSequence": self.solver_step_sequence, - "jacobianEvaluationCount": self.jacobian_evaluation_count, - "thermofluidClosureCount": self.thermofluid_closure_count, - } - - -class SolverActivityTracker: - """Single-writer activity telemetry for a solver worker. - - The solver thread is the only writer and the stream thread only snapshots - scalar attributes. The sequence is published last, so a reader never - treats partially published fields as a newer completed activity update. - Passing no tracker to :func:`integrate_ode` is the zero-cost opt-out path. - """ - - __slots__ = ( - "_accepted_step_sequence", - "_accepted_time", - "_activity_kind", - "_activity_sequence", - "_current_trial_time", - "_jacobian_evaluation_count", - "_rhs_call_count", - "_solver_step_sequence", - "_thermofluid_closure_count", - ) - - def __init__(self) -> None: - self._activity_sequence = 0 - self._activity_kind = "idle" - self._current_trial_time: float | None = None - self._rhs_call_count = 0 - self._accepted_step_sequence = 0 - self._accepted_time: float | None = None - self._solver_step_sequence = 0 - self._jacobian_evaluation_count = 0 - self._thermofluid_closure_count = 0 - - def _publish(self, kind: str, time: float | None = None) -> None: - self._activity_kind = kind - if time is not None: - self._current_trial_time = float(time) - self._activity_sequence += 1 - - def start_integration(self, time: float) -> None: - self._accepted_time = float(time) - self._publish("solver_initialization", time) - - def record_phase(self, kind: str, time: float | None = None) -> None: - self._publish(kind, time) - - def record_solver_step(self, time: float) -> None: - self._solver_step_sequence += 1 - self._publish("solver_step", time) - - def record_rhs(self, time: float) -> None: - self._rhs_call_count += 1 - self._publish("rhs", time) - - def record_jacobian(self, time: float) -> None: - self._jacobian_evaluation_count += 1 - self._publish("jacobian", time) - - def record_thermofluid_closure(self, time: float) -> None: - self._thermofluid_closure_count += 1 - self._publish("thermofluid_closure", time) - - def record_accepted_step(self, time: float) -> None: - accepted_time = float(time) - if ( - self._accepted_time is not None - and accepted_time <= self._accepted_time - ): - return - self._accepted_step_sequence += 1 - self._accepted_time = accepted_time - self._publish("accepted_step", accepted_time) - - def snapshot(self) -> SolverActivitySnapshot: - # ``activity_sequence`` is read last because writers publish it last. - activity_kind = self._activity_kind - current_trial_time = self._current_trial_time - rhs_call_count = self._rhs_call_count - accepted_step_sequence = self._accepted_step_sequence - accepted_time = self._accepted_time - solver_step_sequence = self._solver_step_sequence - jacobian_evaluation_count = self._jacobian_evaluation_count - thermofluid_closure_count = self._thermofluid_closure_count - activity_sequence = self._activity_sequence - return SolverActivitySnapshot( - activity_sequence=activity_sequence, - activity_kind=activity_kind, - current_trial_time=current_trial_time, - rhs_call_count=rhs_call_count, - accepted_step_sequence=accepted_step_sequence, - accepted_time=accepted_time, - solver_step_sequence=solver_step_sequence, - jacobian_evaluation_count=jacobian_evaluation_count, - thermofluid_closure_count=thermofluid_closure_count, - ) - - -@dataclass(frozen=True) -class StateTransition: - """A state reset located inside an accepted integration step.""" - - time: float - state: list[float] - - -StateTransitionHandler = Callable[ - [float, list[float], float, list[float], DenseState], - StateTransition | None, -] - -_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64 -_MAX_RECOVERABLE_RETRIES = 16 -_RECOVERABLE_RETRY_FACTOR = 0.5 - - -class IntegrationCancelled(Exception): - """Internal control-flow signal shared by RHS and Jacobian evaluation.""" - - pass - - -@dataclass(frozen=True) -class SolveIVPConfig: - t_start: float = 0.0 - t_stop: float = 20.0 - method: str = "BDF" - rtol: float = 1e-6 - atol: float | Sequence[float] = 1e-8 - max_step: float = 1e-3 - first_step: float | None = None - - -@dataclass(frozen=True) -class RecoverableRetryDiagnostics: - """One recoverable trial failure and the step cap chosen for its retry.""" - - phase: Literal["constructor", "step", "solver-status"] - attempted_step: float - reason: str - next_max_step: float | None = None - next_first_step: float | None = None - - def as_dict(self) -> dict[str, object]: - result: dict[str, object] = { - "phase": self.phase, - "attemptedStep": self.attempted_step, - "reason": self.reason, - } - if self.next_max_step is not None: - result["nextMaxStep"] = self.next_max_step - if self.next_first_step is not None: - result["nextFirstStep"] = self.next_first_step - return result - - -@dataclass(frozen=True) -class SolverSegmentDiagnostics: - """Work performed by implicit solver instances inside one event segment.""" - - start_time: float - requested_stop_time: float - simulated_until: float - nfev: int = 0 - njev: int = 0 - nlu: int = 0 - accepted_step_count: int = 0 - solver_start_count: int = 0 - state_transition_count: int = 0 - state_transition_times: tuple[float, ...] = () - recoverable_retry_count: int = 0 - jacobian_evaluation_count: int = 0 - jacobian_full_build_count: int = 0 - jacobian_secant_reuse_count: int = 0 - jacobian_audit_failure_count: int = 0 - finite_difference_rhs_evaluation_count: int = 0 - jacobian_base_rhs_evaluation_count: int = 0 - jacobian_jv_audit_rhs_evaluation_count: int = 0 - exact_column_build_count: int = 0 - exact_column_fallback_count: int = 0 - jacobian_assembly_seconds: float = 0.0 - recoverable_retries: tuple[RecoverableRetryDiagnostics, ...] = () - - def as_dict(self) -> dict[str, object]: - result: dict[str, object] = { - "startTime": self.start_time, - "requestedStopTime": self.requested_stop_time, - "simulatedUntil": self.simulated_until, - "nfev": self.nfev, - "njev": self.njev, - "nlu": self.nlu, - "acceptedStepCount": self.accepted_step_count, - "solverStartCount": self.solver_start_count, - "stateTransitionCount": self.state_transition_count, - "recoverableRetryCount": self.recoverable_retry_count, - } - if self.state_transition_times: - result["stateTransitionTimes"] = list( - self.state_transition_times - ) - if self.recoverable_retries: - result["recoverableRetries"] = [ - retry.as_dict() for retry in self.recoverable_retries - ] - if ( - self.jacobian_evaluation_count - or self.finite_difference_rhs_evaluation_count - or self.jacobian_assembly_seconds - ): - result.update( - { - "jacobianEvaluationCount": self.jacobian_evaluation_count, - "jacobianFullBuildCount": self.jacobian_full_build_count, - "jacobianSecantReuseCount": self.jacobian_secant_reuse_count, - "jacobianAuditFailureCount": self.jacobian_audit_failure_count, - "finiteDifferenceRhsEvaluationCount": ( - self.finite_difference_rhs_evaluation_count - ), - "jacobianBaseRhsEvaluationCount": ( - self.jacobian_base_rhs_evaluation_count - ), - "jacobianJvAuditRhsEvaluationCount": ( - self.jacobian_jv_audit_rhs_evaluation_count - ), - "exactColumnBuildCount": self.exact_column_build_count, - "exactColumnFallbackCount": ( - self.exact_column_fallback_count - ), - "jacobianAssemblySeconds": self.jacobian_assembly_seconds, - } - ) - return result - - -_JACOBIAN_DIAGNOSTIC_KEYS = ( - "jacobianEvaluationCount", - "fullBuildCount", - "secantReuseCount", - "auditFailureCount", - "finiteDifferenceRhsEvaluationCount", - "baseRhsEvaluationCount", - "jvAuditEvaluationCount", - "exactColumnBuildCount", - "exactColumnFallbackCount", - "assemblySeconds", -) - - -def _jacobian_diagnostic_snapshot( - jac: JacobianCallable | None, -) -> dict[str, float]: - diagnostics = getattr(jac, "diagnostics", None) - if diagnostics is None: - return {key: 0.0 for key in _JACOBIAN_DIAGNOSTIC_KEYS} - values = diagnostics() - return { - key: float(values.get(key, 0.0)) - for key in _JACOBIAN_DIAGNOSTIC_KEYS - } - - -def _positive_finite_step(value: object) -> float | None: - if value is None: - return None - try: - candidate = abs(float(value)) - except (TypeError, ValueError, OverflowError): - return None - return candidate if candidate > 0.0 and math.isfinite(candidate) else None - - -def _smallest_positive_finite_step(*values: object) -> float: - """Return a conservative step bound from configuration candidates.""" - - candidates = [ - candidate - for value in values - if (candidate := _positive_finite_step(value)) is not None - ] - if not candidates: - raise ValueError("No positive finite integration step is available.") - return min(candidates) - - -def _solver_attempted_step( - solver: object, - *, - segment_max_step: float, - remaining_interval: float, -) -> float: - """Snapshot the real trial scale before calling ``solver.step()``. - - SciPy exposes the proposed step as ``h_abs``. ``step_size`` is the prior - accepted step, so it is only a fallback for solvers without a valid - ``h_abs``; it must not reduce an otherwise valid failed-trial estimate. - """ - - configured_cap = _smallest_positive_finite_step( - segment_max_step, - remaining_interval, - ) - for attribute in ("h_abs", "step_size"): - try: - candidate = _positive_finite_step( - getattr(solver, attribute, None) - ) - except Exception: - # A third-party OdeSolver may implement these as fragile - # properties. The configured cap remains a safe fallback. - continue - if candidate is not None: - return min(candidate, configured_cap) - return configured_cap - - -def _recoverable_retry_steps( - attempted_step: float, - *, - last_accepted_time: float, -) -> tuple[float, float] | None: - """Return strictly smaller max/first steps, or None at machine precision.""" - - next_step = _RECOVERABLE_RETRY_FACTOR * attempted_step - minimum_step = 64.0 * math.ulp(max(abs(last_accepted_time), 1.0)) - if ( - not math.isfinite(next_step) - or next_step <= minimum_step - or next_step >= attempted_step - ): - return None - # This first step is intentionally one-shot. Keeping it equal to the new - # cap makes both controls strictly smaller than the failed trial scale. - return next_step, next_step - - -@dataclass(frozen=True) -class ODESolution: - t: list[float] - y: list[list[float]] - success: bool - message: str - status: IntegrationStatus = "completed" - error: Exception | None = None - solver_segments: tuple[SolverSegmentDiagnostics, ...] = () - - -def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]: - return [x + scale * y for x, y in zip(a, b)] - - -def _append_solution_sample( - times: list[float], - states: list[list[float]], - time: float, - state: list[float], -) -> None: - time = float(time) - if times and time <= times[-1]: - return - times.append(time) - for index, value in enumerate(state): - states[index].append(float(value)) - - -def _append_or_replace_solution_sample( - times: list[float], - states: list[list[float]], - time: float, - state: list[float], -) -> None: - """Store a reset state even when its event time was already sampled.""" - - time = float(time) - if times and time == times[-1]: - times[-1] = time - for index, value in enumerate(state): - states[index][-1] = float(value) - return - _append_solution_sample(times, states, time, state) - -def _project_nearby_pre_transition_sample( - times: list[float], - states: list[list[float]], - transition: StateTransition, - config: SolveIVPConfig, -) -> None: - """Resolve a sample/event ordering that is below solver time precision. - - An adaptive dense interpolant can place a discontinuous impact a few - nanoseconds after its analytically coincident output sample. Keep the - located event and restart time unchanged, but report that ambiguous sample - on the reset side of the discontinuity. - """ - if not times or not math.isfinite(config.max_step): - return - time_gap = float(transition.time) - times[-1] - tolerance = max( - 64.0 * math.ulp(max(abs(float(transition.time)), 1.0)), - min( - abs(float(config.max_step) * float(config.rtol)), - 1.0e-8, - ), - ) - if not 0.0 < time_gap <= tolerance: - return - for index, value in enumerate(transition.state): - states[index][-1] = float(value) - - -def _normalize_state_transition( - transition: StateTransition, - before_time: float, - after_time: float, - state_size: int, -) -> StateTransition: - """Validate and normalize a transition returned for an accepted step.""" - - if not isinstance(transition, StateTransition): - raise TypeError( - "State transition handlers must return StateTransition or None." - ) - - transition_time = float(transition.time) - if not math.isfinite(transition_time): - raise ValueError("State transition times must be finite numbers.") - - tolerance = 16.0 * max( - math.ulp(before_time), - math.ulp(after_time), - math.ulp(transition_time), - ) - if ( - transition_time < before_time - tolerance - or transition_time > after_time + tolerance - ): - raise ValueError( - "State transition time must lie inside the accepted integration step." - ) - transition_time = min(max(transition_time, before_time), after_time) - - transition_state = [float(value) for value in transition.state] - if len(transition_state) != state_size: - raise ValueError( - "State transition reset state must have the same size as the ODE state." - ) - if not all(math.isfinite(value) for value in transition_state): - raise ValueError("State transition reset states must contain finite numbers.") - return StateTransition(time=transition_time, state=transition_state) - - -def _is_repeated_state_transition( - transition: StateTransition, - last_transition: StateTransition | None, -) -> bool: - """Suppress only the exact reset that was just applied. - - A second reset at the same instant is meaningful when it produces a - different state (for example, two constraints becoming active together). - """ - - return ( - last_transition is not None - and transition.time == last_transition.time - and transition.state == last_transition.state - ) - - -def _next_same_time_transition_count( - transition: StateTransition, - last_transition: StateTransition | None, - previous_count: int, -) -> int: - count = ( - previous_count + 1 - if last_transition is not None - and transition.time == last_transition.time - else 1 - ) - if count > _MAX_STATE_TRANSITIONS_AT_SAME_TIME: - raise RuntimeError( - "State transition handler exceeded " - f"{_MAX_STATE_TRANSITIONS_AT_SAME_TIME} chained resets at the same time." - ) - return count - - -def _align_transition_with_exact_endpoint( - transition: StateTransition, - requested_time: float, - exact_endpoint: float | None, -) -> StateTransition: - """Keep an event reported at a breakpoint on that exact public timestamp.""" - - if exact_endpoint is not None and requested_time == exact_endpoint: - return StateTransition( - time=float(exact_endpoint), - state=list(transition.state), - ) - return transition - - -def _normalize_breakpoints( - config: SolveIVPConfig, - breakpoints: Sequence[float] | None, -) -> list[float]: - """Return sorted, unique breakpoints strictly inside the integration span.""" - - if breakpoints is None or len(breakpoints) == 0: - return [] - if config.t_stop < config.t_start: - raise ValueError("Segmented integration requires t_stop to follow t_start.") - - normalized: list[float] = [] - for raw_breakpoint in breakpoints: - breakpoint = float(raw_breakpoint) - if not math.isfinite(breakpoint): - raise ValueError("Integration breakpoints must be finite numbers.") - if config.t_start < breakpoint < config.t_stop: - normalized.append(breakpoint) - - normalized.sort() - return [ - breakpoint - for index, breakpoint in enumerate(normalized) - if index == 0 or breakpoint != normalized[index - 1] - ] - - -def _runge_kutta_4( - rhs: Callable[[float, list[float]], list[float]], - initial_state: list[float], - config: SolveIVPConfig, - t_eval: list[float] | None, - cancel_check: CancellationCheck | None = None, - accepted_step_callback: AcceptedStepCallback | None = None, - state_transition_handler: StateTransitionHandler | None = None, -) -> ODESolution: - if t_eval is None: - point_count = max( - 2, - int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1, - ) - step = (config.t_stop - config.t_start) / (point_count - 1) - t_eval = [config.t_start + index * step for index in range(point_count)] - - state = list(initial_state) - states = [[value] for value in state] - times = [float(t_eval[0])] - current_time = float(t_eval[0]) - status: IntegrationStatus = "completed" - message = "Integrated with built-in RK4 fallback because SciPy is unavailable." - error: Exception | None = None - last_transition: StateTransition | None = None - same_time_transition_count = 0 - last_reported_step: float | None = None - - def report_step(time: float) -> None: - nonlocal last_reported_step - if accepted_step_callback is None: - return - if last_reported_step is not None and time <= last_reported_step: - return - accepted_step_callback(float(time)) - last_reported_step = float(time) - - try: - for target_time in t_eval[1:]: - while current_time < target_time: - if cancel_check is not None and cancel_check(): - raise IntegrationCancelled - dt = min(config.max_step, target_time - current_time) - k1 = rhs(current_time, state) - k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt)) - k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt)) - k4 = rhs(current_time + dt, _vector_add(state, k3, dt)) - next_state = [ - value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d) - for value, a, b, c, d in zip(state, k1, k2, k3, k4) - ] - next_time = current_time + dt - - transition: StateTransition | None = None - if state_transition_handler is not None: - step_start = current_time - step_state = list(state) - - def dense_state(time: float) -> list[float]: - fraction = (float(time) - step_start) / (next_time - step_start) - return [ - before + fraction * (after - before) - for before, after in zip(step_state, next_state) - ] - - candidate = state_transition_handler( - step_start, - list(step_state), - next_time, - list(next_state), - dense_state, - ) - if candidate is not None: - candidate = _normalize_state_transition( - candidate, - step_start, - next_time, - len(state), - ) - if not _is_repeated_state_transition( - candidate, - last_transition, - ): - transition = candidate - - if transition is not None: - same_time_transition_count = _next_same_time_transition_count( - transition, - last_transition, - same_time_transition_count, - ) - current_time = transition.time - state = list(transition.state) - last_transition = transition - _append_or_replace_solution_sample( - times, - states, - current_time, - state, - ) - else: - current_time = next_time - state = next_state - report_step(current_time) - - _append_solution_sample(times, states, target_time, state) - except IntegrationCancelled: - status = "cancelled" - message = "Simulation was stopped before reaching the requested end time." - _append_solution_sample(times, states, current_time, state) - except Exception as exc: - status = "failed" - message = str(exc) - error = exc - _append_solution_sample(times, states, current_time, state) - - return ODESolution( - t=times, - y=states, - success=status == "completed", - message=message, - status=status, - error=error, - ) - - -def _runge_kutta_4_segmented( - rhs: Callable[[float, list[float]], list[float]], - initial_state: list[float], - config: SolveIVPConfig, - t_eval: list[float] | None, - breakpoints: Sequence[float], - cancel_check: CancellationCheck | None = None, - accepted_step_callback: AcceptedStepCallback | None = None, - state_transition_handler: StateTransitionHandler | None = None, -) -> ODESolution: - """RK4 fallback that never evaluates a pre-breakpoint step at the breakpoint.""" - - if t_eval is None: - point_count = max( - 2, - int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1, - ) - sample_step = (config.t_stop - config.t_start) / (point_count - 1) - sample_times = [ - config.t_start + index * sample_step for index in range(point_count) - ] - else: - sample_times = [float(time) for time in t_eval] - - state = [float(value) for value in initial_state] - states = [[value] for value in state] - times = [float(config.t_start)] - current_time = float(config.t_start) - sample_index = 0 - while ( - sample_index < len(sample_times) - and sample_times[sample_index] <= config.t_start - ): - sample_index += 1 - - status: IntegrationStatus = "completed" - message = "Integrated with built-in RK4 fallback because SciPy is unavailable." - error: Exception | None = None - last_transition: StateTransition | None = None - same_time_transition_count = 0 - last_reported_step: float | None = None - - def report_step(time: float) -> None: - nonlocal last_reported_step - if accepted_step_callback is None: - return - if last_reported_step is not None and time <= last_reported_step: - return - accepted_step_callback(float(time)) - last_reported_step = float(time) - - def advance_to( - target_time: float, reported_terminal_time: float | None = None - ) -> None: - nonlocal current_time, last_transition, same_time_transition_count, state - while current_time < target_time: - if cancel_check is not None and cancel_check(): - raise IntegrationCancelled - dt = min(config.max_step, target_time - current_time) - k1 = rhs(current_time, state) - k2 = rhs( - current_time + 0.5 * dt, - _vector_add(state, k1, 0.5 * dt), - ) - k3 = rhs( - current_time + 0.5 * dt, - _vector_add(state, k2, 0.5 * dt), - ) - k4 = rhs(current_time + dt, _vector_add(state, k3, dt)) - next_state = [ - value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d) - for value, a, b, c, d in zip(state, k1, k2, k3, k4) - ] - next_time = current_time + dt - - transition: StateTransition | None = None - if state_transition_handler is not None: - step_start = current_time - step_state = list(state) - - def dense_state(time: float) -> list[float]: - fraction = (float(time) - step_start) / (next_time - step_start) - return [ - before + fraction * (after - before) - for before, after in zip(step_state, next_state) - ] - - candidate = state_transition_handler( - step_start, - list(step_state), - next_time, - list(next_state), - dense_state, - ) - if candidate is not None: - requested_time = float(candidate.time) - candidate = _normalize_state_transition( - candidate, - step_start, - next_time, - len(state), - ) - candidate = _align_transition_with_exact_endpoint( - candidate, - requested_time, - reported_terminal_time, - ) - if not _is_repeated_state_transition( - candidate, - last_transition, - ): - transition = candidate - - if transition is not None: - same_time_transition_count = _next_same_time_transition_count( - transition, - last_transition, - same_time_transition_count, - ) - current_time = transition.time - state = list(transition.state) - last_transition = transition - _append_or_replace_solution_sample( - times, - states, - current_time, - state, - ) - else: - current_time = next_time - state = next_state - report_time = current_time - if ( - reported_terminal_time is not None - and current_time >= target_time - ): - report_time = reported_terminal_time - report_step(report_time) - - try: - segment_ends = [*breakpoints, float(config.t_stop)] - for segment_index, segment_end in enumerate(segment_ends): - is_breakpoint = segment_index < len(breakpoints) - integration_end = ( - math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end - ) - - while ( - sample_index < len(sample_times) - and sample_times[sample_index] <= integration_end - ): - sample_time = float(sample_times[sample_index]) - advance_to(sample_time) - _append_solution_sample(times, states, sample_time, state) - sample_index += 1 - - advance_to( - integration_end, - segment_end if is_breakpoint else None, - ) - - if is_breakpoint: - current_time = float(segment_end) - report_step(current_time) - while ( - sample_index < len(sample_times) - and sample_times[sample_index] <= segment_end - ): - sample_time = float(sample_times[sample_index]) - _append_solution_sample(times, states, sample_time, state) - sample_index += 1 - except IntegrationCancelled: - status = "cancelled" - message = "Simulation was stopped before reaching the requested end time." - _append_solution_sample(times, states, current_time, state) - except Exception as exc: - status = "failed" - message = str(exc) - error = exc - _append_solution_sample(times, states, current_time, state) - - return ODESolution( - t=times, - y=states, - success=status == "completed", - message=message, - status=status, - error=error, - ) - - -def _integrate_scipy_stepwise( - rhs: Callable[[float, list[float]], list[float]], - initial_state: list[float], - config: SolveIVPConfig, - t_eval: list[float] | None, - cancel_check: CancellationCheck, - accepted_step_callback: AcceptedStepCallback | None, - breakpoints: Sequence[float] = (), - state_transition_handler: StateTransitionHandler | None = None, - jac_sparsity=None, - jac: JacobianCallable | None = None, - activity_tracker: SolverActivityTracker | None = None, -) -> ODESolution: - """Initial stepwise integration path for breakpoints and state resets. - - Recoverable physical-domain failures from rejected integrator trial states - restore the last accepted state and rebuild the same solver with a smaller - maximum/first step. Structural, algebraic, and ordinary model errors still - fail immediately. - """ - import numpy as np - from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau - - solver_types = { - "BDF": BDF, - "DOP853": DOP853, - "LSODA": LSODA, - "RK23": RK23, - "RK45": RK45, - "Radau": Radau, - } - solver_type = solver_types.get(config.method) - if solver_type is None: - raise ValueError(f"Unsupported integration method: {config.method}") - implicit_jac = jac if config.method in {"BDF", "Radau"} else None - solver_jac = implicit_jac - if implicit_jac is not None and activity_tracker is not None: - original_jacobian = implicit_jac - - def activity_jacobian(time, state): - activity_tracker.record_jacobian(float(time)) - try: - return original_jacobian(time, state) - finally: - activity_tracker.record_phase("solver_step", float(time)) - - solver_jac = activity_jacobian - - times = [float(config.t_start)] - states = [[float(value)] for value in initial_state] - last_accepted_time = float(config.t_start) - last_accepted_state = [float(value) for value in initial_state] - sample_times = [float(time) for time in (t_eval or [])] - sample_index = 0 - while ( - sample_index < len(sample_times) - and sample_times[sample_index] <= config.t_start - ): - sample_index += 1 - - def cancellable_rhs(time, state): - if cancel_check(): - raise IntegrationCancelled - normalized_state = [float(value) for value in state] - derivative = rhs(float(time), normalized_state) - observer = getattr(implicit_jac, "observe", None) - if observer is not None: - observer(float(time), normalized_state, derivative) - return derivative - - status: IntegrationStatus = "completed" - message = "The solver successfully reached the end of the integration interval." - error: Exception | None = None - last_transition: StateTransition | None = None - same_time_transition_count = 0 - integration_progressed = False - last_reported_step: float | None = None - solver_segments: list[SolverSegmentDiagnostics] = [] - - def cancellation_message() -> str: - return ( - "Simulation was stopped before reaching the requested end time." - if integration_progressed - else "Simulation was stopped before integration started." - ) - - def report_step(time: float) -> None: - nonlocal last_reported_step - if accepted_step_callback is None: - return - if last_reported_step is not None and time <= last_reported_step: - return - accepted_step_callback(float(time)) - last_reported_step = float(time) - - segment_ends = [*breakpoints, float(config.t_stop)] - for segment_index, segment_end in enumerate(segment_ends): - if cancel_check(): - status = "cancelled" - message = cancellation_message() - break - - is_breakpoint = segment_index < len(breakpoints) - integration_end = ( - math.nextafter(segment_end, -math.inf) if is_breakpoint else segment_end - ) - has_integration_interval = integration_end > last_accepted_time - segment_start_time = last_accepted_time - segment_max_step = float(config.max_step) - recoverable_retry_count = 0 - last_recoverable_error: RecoverableTrialStateError | None = None - retry_first_step: float | None = None - segment_nfev = 0 - segment_njev = 0 - segment_nlu = 0 - segment_accepted_steps = 0 - segment_solver_starts = 0 - segment_state_transitions = 0 - segment_state_transition_times: list[float] = [] - segment_recoverable_retries = 0 - segment_recoverable_retry_diagnostics: list[ - RecoverableRetryDiagnostics - ] = [] - jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac) - - while has_integration_interval and last_accepted_time < integration_end: - if cancel_check(): - status = "cancelled" - message = cancellation_message() - break - - solver_options = { - "rtol": config.rtol, - "atol": config.atol, - "max_step": segment_max_step, - } - if config.method in {"BDF", "Radau"}: - if solver_jac is not None: - solver_options["jac"] = solver_jac - elif jac_sparsity is not None: - solver_options["jac_sparsity"] = jac_sparsity - requested_first_step = ( - retry_first_step - if retry_first_step is not None - else config.first_step - ) - if requested_first_step is not None: - solver_options["first_step"] = min( - requested_first_step, - integration_end - last_accepted_time, - ) - try: - constructor_attempted_step = _smallest_positive_finite_step( - segment_max_step, - integration_end - last_accepted_time, - solver_options.get("first_step"), - ) - start_segment = getattr(implicit_jac, "start_segment", None) - if start_segment is not None: - start_segment() - if activity_tracker is not None: - activity_tracker.record_phase( - "solver_initialization", - last_accepted_time, - ) - solver = solver_type( - cancellable_rhs, - last_accepted_time, - np.asarray(last_accepted_state, dtype=float), - integration_end, - **solver_options, - ) - except IntegrationCancelled: - status = "cancelled" - message = cancellation_message() - break - except RecoverableTrialStateError as exc: - recoverable_retry_count += 1 - segment_recoverable_retries += 1 - last_recoverable_error = exc - retry_steps = ( - _recoverable_retry_steps( - constructor_attempted_step, - last_accepted_time=last_accepted_time, - ) - if recoverable_retry_count <= _MAX_RECOVERABLE_RETRIES - else None - ) - segment_recoverable_retry_diagnostics.append( - RecoverableRetryDiagnostics( - phase="constructor", - attempted_step=constructor_attempted_step, - reason=str(exc), - next_max_step=( - retry_steps[0] if retry_steps is not None else None - ), - next_first_step=( - retry_steps[1] if retry_steps is not None else None - ), - ) - ) - if retry_steps is None: - status = "failed" - message = str(exc) - error = exc - break - segment_max_step, retry_first_step = retry_steps - continue - except Exception as exc: - status = "failed" - message = str(exc) - error = exc - break - - segment_solver_starts += 1 - - restart_at_transition = False - restart_after_recoverable = False - while solver.status == "running": - if cancel_check(): - status = "cancelled" - message = ( - "Simulation was stopped before reaching the requested end time." - ) - break - - step_start_time = last_accepted_time - step_start_state = list(last_accepted_state) - try: - attempted_step = _solver_attempted_step( - solver, - segment_max_step=segment_max_step, - remaining_interval=( - integration_end - last_accepted_time - ), - ) - if activity_tracker is not None: - activity_tracker.record_solver_step( - last_accepted_time - ) - step_message = solver.step() - except IntegrationCancelled: - status = "cancelled" - message = ( - "Simulation was stopped before reaching the requested end time." - ) - break - except RecoverableTrialStateError as exc: - recoverable_retry_count += 1 - segment_recoverable_retries += 1 - last_recoverable_error = exc - retry_steps = ( - _recoverable_retry_steps( - attempted_step, - last_accepted_time=last_accepted_time, - ) - if recoverable_retry_count - <= _MAX_RECOVERABLE_RETRIES - else None - ) - segment_recoverable_retry_diagnostics.append( - RecoverableRetryDiagnostics( - phase="step", - attempted_step=attempted_step, - reason=str(exc), - next_max_step=( - retry_steps[0] - if retry_steps is not None - else None - ), - next_first_step=( - retry_steps[1] - if retry_steps is not None - else None - ), - ) - ) - if retry_steps is None: - status = "failed" - message = str(exc) - error = exc - break - segment_max_step, retry_first_step = retry_steps - restart_after_recoverable = True - break - except Exception as exc: - status = "failed" - message = str(exc) - error = exc - break - - integration_progressed = True - if solver.status == "failed": - if last_recoverable_error is not None: - recoverable_retry_count += 1 - segment_recoverable_retries += 1 - retry_steps = ( - _recoverable_retry_steps( - attempted_step, - last_accepted_time=last_accepted_time, - ) - if recoverable_retry_count - <= _MAX_RECOVERABLE_RETRIES - else None - ) - failure_reason = str( - step_message or last_recoverable_error - ) - segment_recoverable_retry_diagnostics.append( - RecoverableRetryDiagnostics( - phase="solver-status", - attempted_step=attempted_step, - reason=failure_reason, - next_max_step=( - retry_steps[0] - if retry_steps is not None - else None - ), - next_first_step=( - retry_steps[1] - if retry_steps is not None - else None - ), - ) - ) - if retry_steps is not None: - segment_max_step, retry_first_step = retry_steps - restart_after_recoverable = True - break - status = "failed" - message = str(step_message or "Integration step failed.") - break - - # A returned running/finished status means this step was - # accepted. Any prior recoverable failure is now historical: - # it must not influence an event restart or an ordinary later - # solver failure. The reduced cap is local to the failed - # trial: after one accepted retry step, let this solver grow - # adaptively again and ensure a later event restart receives - # the configured maximum. The retry-specific first step is - # likewise strictly one-shot. - if retry_first_step is not None: - segment_max_step = float(config.max_step) - try: - solver.max_step = segment_max_step - except (AttributeError, TypeError, ValueError): - # Third-party OdeSolver-compatible test doubles may not - # expose a writable cap. SciPy's supported solvers do. - pass - last_recoverable_error = None - retry_first_step = None - recoverable_retry_count = 0 - segment_accepted_steps += 1 - step_end_time = float(solver.t) - step_end_state = [float(value) for value in solver.y] - crosses_sample = ( - sample_index < len(sample_times) - and sample_times[sample_index] <= step_end_time - ) - dense_output = ( - solver.dense_output() - if crosses_sample or state_transition_handler is not None - else None - ) - - transition: StateTransition | None = None - if state_transition_handler is not None: - assert dense_output is not None - - def dense_state(time: float) -> list[float]: - return [float(value) for value in dense_output(float(time))] - - try: - candidate = state_transition_handler( - step_start_time, - list(step_start_state), - step_end_time, - list(step_end_state), - dense_state, - ) - if candidate is not None: - requested_time = float(candidate.time) - candidate = _normalize_state_transition( - candidate, - step_start_time, - step_end_time, - len(last_accepted_state), - ) - candidate = _align_transition_with_exact_endpoint( - candidate, - requested_time, - float(segment_end) if is_breakpoint else None, - ) - if not _is_repeated_state_transition( - candidate, - last_transition, - ): - transition = candidate - except Exception as exc: - status = "failed" - message = str(exc) - error = exc - break - - if transition is not None: - segment_state_transitions += 1 - segment_state_transition_times.append( - float(transition.time) - ) - try: - same_time_transition_count = ( - _next_same_time_transition_count( - transition, - last_transition, - same_time_transition_count, - ) - ) - except Exception as exc: - status = "failed" - message = str(exc) - error = exc - break - - while ( - sample_index < len(sample_times) - and sample_times[sample_index] < transition.time - ): - sample_time = float(sample_times[sample_index]) - assert dense_output is not None - sample_state = [ - float(value) for value in dense_output(sample_time) - ] - _append_solution_sample( - times, - states, - sample_time, - sample_state, - ) - sample_index += 1 - - _project_nearby_pre_transition_sample( - times, - states, - transition, - config, - ) - last_accepted_time = transition.time - last_accepted_state = list(transition.state) - last_transition = transition - _append_or_replace_solution_sample( - times, - states, - last_accepted_time, - last_accepted_state, - ) - while ( - sample_index < len(sample_times) - and sample_times[sample_index] <= last_accepted_time - ): - sample_index += 1 - report_step(last_accepted_time) - restart_at_transition = last_accepted_time < integration_end - break - - last_accepted_time = step_end_time - last_accepted_state = step_end_state - reported_time = ( - float(segment_end) - if is_breakpoint and solver.status == "finished" - else last_accepted_time - ) - if sample_times: - while ( - sample_index < len(sample_times) - and sample_times[sample_index] <= last_accepted_time - ): - assert dense_output is not None - sample_time = float(sample_times[sample_index]) - sample_state = [ - float(value) for value in dense_output(sample_time) - ] - _append_solution_sample( - times, - states, - sample_time, - sample_state, - ) - sample_index += 1 - else: - _append_solution_sample( - times, - states, - reported_time, - last_accepted_state, - ) - report_step(reported_time) - - segment_nfev += int(getattr(solver, "nfev", 0)) - segment_njev += int(getattr(solver, "njev", 0)) - segment_nlu += int(getattr(solver, "nlu", 0)) - if status != "completed": - break - if restart_after_recoverable: - continue - if not restart_at_transition: - break - - jacobian_work_end = _jacobian_diagnostic_snapshot(implicit_jac) - jacobian_work = { - key: jacobian_work_end[key] - jacobian_work_start[key] - for key in _JACOBIAN_DIAGNOSTIC_KEYS - } - solver_segments.append( - SolverSegmentDiagnostics( - start_time=float(segment_start_time), - requested_stop_time=float(segment_end), - simulated_until=float( - segment_end if status == "completed" else last_accepted_time - ), - nfev=segment_nfev, - njev=segment_njev, - nlu=segment_nlu, - accepted_step_count=segment_accepted_steps, - solver_start_count=segment_solver_starts, - state_transition_count=segment_state_transitions, - state_transition_times=tuple( - segment_state_transition_times - ), - recoverable_retry_count=segment_recoverable_retries, - recoverable_retries=tuple( - segment_recoverable_retry_diagnostics - ), - jacobian_evaluation_count=int( - jacobian_work["jacobianEvaluationCount"] - ), - jacobian_full_build_count=int( - jacobian_work["fullBuildCount"] - ), - jacobian_secant_reuse_count=int( - jacobian_work["secantReuseCount"] - ), - jacobian_audit_failure_count=int( - jacobian_work["auditFailureCount"] - ), - finite_difference_rhs_evaluation_count=int( - jacobian_work["finiteDifferenceRhsEvaluationCount"] - ), - jacobian_base_rhs_evaluation_count=int( - jacobian_work["baseRhsEvaluationCount"] - ), - jacobian_jv_audit_rhs_evaluation_count=int( - jacobian_work["jvAuditEvaluationCount"] - ), - exact_column_build_count=int( - jacobian_work["exactColumnBuildCount"] - ), - exact_column_fallback_count=int( - jacobian_work["exactColumnFallbackCount"] - ), - jacobian_assembly_seconds=jacobian_work["assemblySeconds"], - ) - ) - if status != "completed": - break - - if is_breakpoint: - # The old equation is integrated only to the representable point just - # left of the event. The continuous state is then lifted to the exact - # event time, where the freshly constructed next solver sees the new - # equation immediately. - last_accepted_time = float(segment_end) - if sample_times: - while ( - sample_index < len(sample_times) - and sample_times[sample_index] <= segment_end - ): - sample_time = float(sample_times[sample_index]) - _append_solution_sample( - times, - states, - sample_time, - last_accepted_state, - ) - sample_index += 1 - elif not has_integration_interval: - _append_solution_sample( - times, - states, - last_accepted_time, - last_accepted_state, - ) - report_step(last_accepted_time) - - if status != "completed": - _append_solution_sample( - times, - states, - last_accepted_time, - last_accepted_state, - ) - - return ODESolution( - t=times, - y=states, - success=status == "completed", - message=message, - status=status, - error=error, - solver_segments=tuple(solver_segments), - ) - - -@profile_phase("simulation.integration") -def integrate_ode( - rhs: Callable[[float, list[float]], list[float]], - initial_state: list[float], - config: SolveIVPConfig, - t_eval: list[float] | None = None, - cancel_check: CancellationCheck | None = None, - accepted_step_callback: AcceptedStepCallback | None = None, - breakpoints: Sequence[float] | None = None, - state_transition_handler: StateTransitionHandler | None = None, - jac_sparsity=None, - jac: JacobianCallable | None = None, - recoverable_trial_retries: bool = False, - activity_tracker: SolverActivityTracker | None = None, -): - """Integrate an ODE, optionally restarting at equation discontinuities. - - Breakpoints are interpreted as right-continuous equation changes: the old - equation is integrated to the floating-point left limit, then a fresh solver - starts at the exact breakpoint with the unchanged continuous state. - - A state transition handler inspects every accepted step using its dense - interpolant. When it returns a transition, samples before the event retain - the pre-event trajectory, the reset state is stored at the event, and a fresh - solver continues from that state. - - ``recoverable_trial_retries`` opts an eventless/cancellation-free caller - into the stepwise path so a ``RecoverableTrialStateError`` can rebuild the - solver from its last accepted state. It defaults to false to preserve the - direct ``solve_ivp`` path for ordinary callers. - - ``activity_tracker`` is optional and additive. When omitted, the numerical - call path and callback behavior are unchanged. - """ - - integration_rhs = rhs - integration_accepted_step_callback = accepted_step_callback - if activity_tracker is not None: - activity_tracker.start_integration(config.t_start) - original_rhs = rhs - - def activity_rhs(time, state): - numeric_time = float(time) - activity_tracker.record_rhs(numeric_time) - try: - return original_rhs(time, state) - finally: - activity_tracker.record_phase("solver_step", numeric_time) - - integration_rhs = activity_rhs - - def activity_accepted_step(time: float) -> None: - activity_tracker.record_accepted_step(float(time)) - if accepted_step_callback is not None: - accepted_step_callback(float(time)) - - integration_accepted_step_callback = activity_accepted_step - - if ( - state_transition_handler is not None - and config.t_stop < config.t_start - ): - raise ValueError( - "State transition handling does not support reverse integration." - ) - - if config.t_stop == config.t_start: - return ODESolution( - t=[float(config.t_start)], - y=[[value] for value in initial_state], - success=True, - message="Skipped integration because t_start equals t_stop.", - ) - - normalized_breakpoints = _normalize_breakpoints(config, breakpoints) - - try: - from scipy.integrate import solve_ivp - except ImportError: - if normalized_breakpoints: - return _runge_kutta_4_segmented( - integration_rhs, - initial_state, - config, - t_eval, - normalized_breakpoints, - cancel_check, - integration_accepted_step_callback, - state_transition_handler, - ) - return _runge_kutta_4( - integration_rhs, - initial_state, - config, - t_eval, - cancel_check, - integration_accepted_step_callback, - state_transition_handler, - ) - - if ( - cancel_check is not None - or normalized_breakpoints - or state_transition_handler is not None - or recoverable_trial_retries - ): - return _integrate_scipy_stepwise( - integration_rhs, - initial_state, - config, - t_eval, - cancel_check or (lambda: False), - integration_accepted_step_callback, - normalized_breakpoints, - state_transition_handler, - jac_sparsity, - jac, - activity_tracker, - ) - - implicit_jac = jac if config.method in {"BDF", "Radau"} else None - solve_rhs = integration_rhs - if implicit_jac is not None: - observer = getattr(implicit_jac, "observe", None) - if observer is not None: - def observed_rhs(time, state): - derivative = integration_rhs(time, state) - observer(float(time), state, derivative) - return derivative - - solve_rhs = observed_rhs - start_segment = getattr(implicit_jac, "start_segment", None) - if start_segment is not None: - start_segment() - - solve_jac = implicit_jac - if implicit_jac is not None and activity_tracker is not None: - original_jacobian = implicit_jac - - def activity_jacobian(time, state): - activity_tracker.record_jacobian(float(time)) - try: - return original_jacobian(time, state) - finally: - activity_tracker.record_phase("solver_step", float(time)) - - solve_jac = activity_jacobian - - solve_options = { - "fun": solve_rhs, - "t_span": (config.t_start, config.t_stop), - "y0": initial_state, - "method": config.method, - "rtol": config.rtol, - "atol": config.atol, - "max_step": config.max_step, - "t_eval": t_eval, - } - if config.first_step is not None: - solve_options["first_step"] = config.first_step - if solve_jac is not None: - solve_options["jac"] = solve_jac - elif jac_sparsity is not None and config.method in {"BDF", "Radau"}: - solve_options["jac_sparsity"] = jac_sparsity - 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 diff --git a/app/simulation/solvers/stream.py b/app/simulation/solvers/stream.py deleted file mode 100644 index 1c9a460..0000000 --- a/app/simulation/solvers/stream.py +++ /dev/null @@ -1,204 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from app.simulation.core.base import Component, DynamicComponent -from app.simulation.core.ports import PortState -from app.simulation.performance import profile_phase -from app.simulation.systems.network import SimulationNetwork - - -class StreamSolveError(RuntimeError): - def __init__(self, message: str, diagnostics: "StreamSolveDiagnostics") -> None: - super().__init__(message) - self.diagnostics = diagnostics - - -@dataclass(frozen=True) -class StreamSolveDiagnostics: - converged: bool - iterations: int - max_delta: float - - def as_dict(self) -> dict[str, object]: - return { - "converged": self.converged, - "iterations": self.iterations, - "maxDelta": self.max_delta, - } - - -@dataclass(frozen=True) -class _StreamConnectionBinding: - component_name: str - port_name: str - connected_component: Component - connected_port: PortState - - -class StreamResolver: - """Resolve outflow enthalpy propagation after pressure and flow are known.""" - - def __init__( - self, - network: SimulationNetwork, - *, - relative_tolerance: float = 1e-9, - max_iterations: int = 100, - ) -> None: - self.network = network - self.relative_tolerance = relative_tolerance - self.max_iterations = max_iterations - self._components = tuple(network.components.values()) - self._dynamic_components = tuple( - component - for component in self._components - if isinstance(component, DynamicComponent) - ) - self._non_dynamic_components = tuple( - component - for component in self._components - if not isinstance(component, DynamicComponent) - ) - # State ownership and pressure-flow stream sensitivity are independent - # classifications. Compile this hook by behavior so algebraic - # components such as PNL00R receive their upstream-temperature - # references without dispatching a no-op to every component at runtime. - self._flow_temperature_reference_components = tuple( - component - for component in self._components - if type(component).update_flow_temperature_references - is not Component.update_flow_temperature_references - ) - self._ports = tuple( - (component.name, port_name, port) - for component in self._components - for port_name, port in component.ports.items() - ) - self._connection_bindings = self._build_connection_bindings() - self.last_diagnostics: StreamSolveDiagnostics | None = None - - def _build_connection_bindings(self) -> tuple[_StreamConnectionBinding, ...]: - result: list[_StreamConnectionBinding] = [] - for connection in self.network.connections: - if connection.kind != "physical": - continue - first, second = connection.endpoints - first_component = self.network.components[first.component] - second_component = self.network.components[second.component] - result.append( - _StreamConnectionBinding( - component_name=first.component, - port_name=first.port, - connected_component=second_component, - connected_port=second_component.get_port(second.port), - ) - ) - result.append( - _StreamConnectionBinding( - component_name=second.component, - port_name=second.port, - connected_component=first_component, - connected_port=first_component.get_port(first.port), - ) - ) - return tuple(result) - - def connected_enthalpies(self) -> dict[str, dict[str, float]]: - values: dict[str, dict[str, float]] = { - component.name: {} for component in self._components - } - for binding in self._connection_bindings: - values[binding.component_name][binding.port_name] = ( - binding.connected_port.h_outflow - ) - return values - - def connected_temperature_reference_enthalpies( - self, - ) -> dict[str, dict[str, float]]: - """Return connector references used for upstream temperature only.""" - - values: dict[str, dict[str, float]] = { - component.name: {} for component in self._components - } - for binding in self._connection_bindings: - values[binding.component_name][binding.port_name] = float( - getattr( - binding.connected_component, - "temperature_reference_h", - binding.connected_port.h_outflow, - ) - ) - return values - - @profile_phase("simulation.refresh", minimum_mode="audit") - def refresh_flow_temperature_references(self) -> None: - """Refresh pressure-flow property inputs without changing stream outflows.""" - - connected = self.connected_temperature_reference_enthalpies() - for component in self._flow_temperature_reference_components: - component.update_flow_temperature_references( - connected[component.name] - ) - - @profile_phase("simulation.refresh", minimum_mode="audit") - def _refresh_dynamic_components(self) -> None: - for component in self._dynamic_components: - component.refresh_thermodynamic_ports() - - @profile_phase("simulation.refresh", minimum_mode="audit") - def _refresh_stream_components( - self, - connected: dict[str, dict[str, float]], - ) -> None: - for component in self._non_dynamic_components: - component.update_stream_outflows(connected[component.name]) - - @profile_phase("simulation.stream", minimum_mode="audit") - def solve( - self, - *, - dynamic_ports_are_current: bool = False, - ) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]: - if not dynamic_ports_are_current: - self._refresh_dynamic_components() - - max_delta = 0.0 - for iteration in range(1, self.max_iterations + 1): - previous = { - (component_name, port_name): port.h_outflow - for component_name, port_name, port in self._ports - } - connected = self.connected_enthalpies() - self._refresh_stream_components(connected) - - deltas = [ - abs(port.h_outflow - previous[(component_name, port_name)]) - for component_name, port_name, port in self._ports - ] - magnitudes = [ - abs(port.h_outflow) - for _component_name, _port_name, port in self._ports - ] - max_delta = max(deltas, default=0.0) - scale = max(magnitudes + [1.0]) - if max_delta <= self.relative_tolerance * scale: - diagnostics = StreamSolveDiagnostics( - converged=True, - iterations=iteration, - max_delta=max_delta, - ) - self.last_diagnostics = diagnostics - return diagnostics, self.connected_enthalpies() - - diagnostics = StreamSolveDiagnostics( - converged=False, - iterations=self.max_iterations, - max_delta=max_delta, - ) - self.last_diagnostics = diagnostics - raise StreamSolveError( - "Stream enthalpy propagation did not converge.", - diagnostics, - ) diff --git a/app/simulation/solvers/tangent.py b/app/simulation/solvers/tangent.py deleted file mode 100644 index 0414729..0000000 --- a/app/simulation/solvers/tangent.py +++ /dev/null @@ -1,1007 +0,0 @@ -"""Proof-gated tangent columns for supported piston branch networks. - -This module is deliberately narrower than the generic algebraic solver. It -only compiles a tangent provider after proving the state layout, component -types, physical connections, and causal execution plan used by every selected -piston branch. The legacy three-piston entry point remains available for its -committed fixture, while the topology-driven entry point discovers any number -of branches without depending on component names. A failed proof leaves the -ordinary seed-0 numerical Jacobian in control; a runtime mode boundary requests -the same one-build fallback through :class:`ExactColumnsUnavailable`. -""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from dataclasses import dataclass -from math import isfinite -from typing import TYPE_CHECKING - -import numpy as np - -from app.simulation.solvers.jacobian import ExactColumnsUnavailable -from app.simulation.solvers.mechanical import MechanicalConstraintGroup -from app.simulation.systems.network import Endpoint - -if TYPE_CHECKING: - from app.simulation.systems.generic import GenericFluidSystem - - -_TARGET_BRANCH_NAMES = ( - ( - "mass_friction_endstops_10", - "pn_brp2_8", - "pn_c1_8", - "pneumatic_69", - "elasticendstop_8", - ), - ( - "mass_friction_endstops_11", - "pn_brp2_9", - "pn_c1_9", - "pneumatic_68", - "elasticendstop_9", - ), - ( - "mass_friction_endstops_12", - "pn_brp2_10", - "pn_c1_10", - "pneumatic_66", - "elasticendstop_10", - ), -) - - -@dataclass(frozen=True) -class ThreePistonBranch: - mass: object - piston: object - chamber: object - pipe: object - contact: object - chamber_connection_port: str - velocity_index: int - position_index: int - - -@dataclass(frozen=True) -class ThreePistonTangentCompilation: - eligible: bool - reason: str | None - columns: tuple[int, ...] = () - provider: "ThreePistonTangentProvider | None" = None - reached_assignment_count: int = 0 - - def diagnostics(self) -> dict[str, object]: - return { - "eligible": self.eligible, - "fallbackReason": self.reason, - "columns": list(self.columns), - "columnCount": len(self.columns), - "reachedAssignmentCount": self.reached_assignment_count, - } - - -@dataclass(frozen=True) -class _PrimalContext: - time: float - state: np.ndarray - connected_h: dict[str, dict[str, float]] - - -def _failed(reason: str) -> ThreePistonTangentCompilation: - return ThreePistonTangentCompilation(False, reason) - - -class ThreePistonTangentProvider: - """Batched selected-branch provider compiled for one system instance.""" - - def __init__( - self, - system: "GenericFluidSystem", - branches: tuple[ThreePistonBranch, ...], - columns: tuple[int, ...], - state_offsets: Mapping[str, tuple[int, int]], - reached_assignment_count: int, - ) -> None: - self.system = system - self.branches = branches - self.columns = columns - self.state_offsets = dict(state_offsets) - self.reached_assignment_count = reached_assignment_count - self._context: _PrimalContext | None = None - self._capture_requested = False - - def request_primal_capture(self) -> None: - """Capture exactly the next completed RHS primal closure.""" - - self._capture_requested = True - - def cancel_primal_capture(self) -> None: - """Discard a pending capture after an interrupted base RHS.""" - - self._capture_requested = False - - def record_primal( - self, - time: float, - state: Sequence[float], - connected_h: Mapping[str, Mapping[str, float]], - ) -> None: - if not self._capture_requested: - return - self._capture_requested = False - required_components = { - item.name - for branch in self.branches - for item in (branch.chamber, branch.pipe) - } - self._context = _PrimalContext( - time=float(time), - state=np.asarray(state, dtype=float).copy(), - connected_h={ - name: {port: float(value) for port, value in values.items()} - for name, values in connected_h.items() - if name in required_components - }, - ) - - def __call__( - self, - time: float, - state: np.ndarray, - normalized_indexes: tuple[int, ...], - ) -> np.ndarray: - if normalized_indexes != self.columns: - raise ValueError("Compiled tangent columns were requested out of order.") - context = self._context - values = np.asarray(state, dtype=float) - if ( - context is None - or float(time) != context.time - or values.shape != context.state.shape - or not np.array_equal(values, context.state) - ): - raise ExactColumnsUnavailable("stalePrimalContext") - return self._evaluate(context) - - @staticmethod - def _zeros(width: int) -> tuple[float, ...]: - return (0.0,) * width - - @staticmethod - def _has_signal(vector: Sequence[float]) -> bool: - return any(float(value) != 0.0 for value in vector) - - @staticmethod - def _require_valid(value: object, prefix: str) -> None: - if not bool(getattr(value, "valid", False)): - reason = getattr(value, "reason", None) or "invalid" - raise ExactColumnsUnavailable(f"{prefix}:{reason}") - - def _evaluate(self, context: _PrimalContext) -> np.ndarray: - # The primal RHS may disable the causal fast path after a residual - # audit. Recheck after that closure and before replaying its compiled - # assignments so a dynamic downgrade uses the ordinary full FD build. - if not self.system.pressure_flow_solver.causal_fast_path_enabled: - raise ExactColumnsUnavailable("causalFastPathDisabled") - width = len(self.columns) - zero = self._zeros(width) - out = np.zeros((len(context.state), width), dtype=float) - seeds = { - column: tuple(float(index == seed_index) for index in range(width)) - for seed_index, column in enumerate(self.columns) - } - solver = self.system.pressure_flow_solver - tangents: dict[str, tuple[float, ...]] = {} - - def tangent(key: str) -> tuple[float, ...]: - return tangents.get(key, zero) - - def negative_sum( - vectors: Sequence[Sequence[float]], - ) -> tuple[float, ...]: - return tuple( - -sum(float(vector[index]) for vector in vectors) - for index in range(width) - ) - - for variable in ("x", "v"): - plan = solver._causal_effort_plan_by_variable.get(variable) - if plan is None: - raise ExactColumnsUnavailable("causalEffortPlanUnavailable") - for assignment in plan: - matched = [ - branch - for branch in self.branches - if any( - unknown.component == branch.mass.name - for unknown in assignment.members - ) - ] - if len(matched) > 1: - raise ExactColumnsUnavailable("coupledTargetMechanicalSeeds") - vector = zero - if matched: - branch = matched[0] - column = ( - branch.position_index - if variable == "x" - else branch.velocity_index - ) - vector = seeds[column] - for member in assignment.members: - tangents[member.id] = vector - - geometry: dict[str, object] = {} - chamber_properties: dict[str, object] = {} - for branch in self.branches: - piston = branch.piston - linearization = piston.linearize_geometry_and_force( - tangent(f"{piston.name}.port_4.x"), - tangent(f"{piston.name}.port_5.x"), - tangent(f"{piston.name}.port_4.v"), - tangent(f"{piston.name}.port_5.v"), - zero, - ) - self._require_valid(linearization, "pistonGeometry") - chamber = branch.chamber - volume = float(chamber.total_volume()) - if volume <= float(chamber.cvol0) / 100.0: - raise ExactColumnsUnavailable("volumeFloor") - primal = chamber.medium.properties_from_mU( - chamber.state.m, - chamber.state.U, - volume, - ) - properties = chamber.medium.linearize_properties_from_mU( - chamber.state.m, - chamber.state.U, - volume, - zero, - zero, - linearization.volume_tangent, - properties=primal, - ) - self._require_valid(properties, "chamberProperties") - geometry[piston.name] = linearization - chamber_properties[chamber.name] = properties - - pressure_plan = solver._causal_effort_plan_by_variable.get("p") - if pressure_plan is None: - raise ExactColumnsUnavailable("causalPressurePlanUnavailable") - for assignment in pressure_plan: - matched = [ - branch - for branch in self.branches - if any( - unknown.component == branch.chamber.name - for unknown in assignment.members - ) - ] - if len(matched) > 1: - raise ExactColumnsUnavailable("coupledTargetPressureSeeds") - vector = ( - chamber_properties[matched[0].chamber.name].tangents.p - if matched - else zero - ) - for member in assignment.members: - tangents[member.id] = tuple(vector) - - contacts: dict[str, object] = {} - for branch in self.branches: - piston = branch.piston - linearization = piston.linearize_geometry_and_force( - tangent(f"{piston.name}.port_4.x"), - tangent(f"{piston.name}.port_5.x"), - tangent(f"{piston.name}.port_4.v"), - tangent(f"{piston.name}.port_5.v"), - tangent(f"{piston.name}.port_1.p"), - ) - self._require_valid(linearization, "pistonPressureForce") - geometry[piston.name] = linearization - contact = branch.contact - contact_linearization = contact.linearize_contact_force( - tangent(f"{contact.name}.port_1.x"), - tangent(f"{contact.name}.port_2.x"), - tangent(f"{contact.name}.port_1.v"), - tangent(f"{contact.name}.port_2.v"), - ) - self._require_valid(contact_linearization, "contactMode") - contacts[contact.name] = contact_linearization - - equations = { - equation.id: equation for equation in solver.equation_templates - } - branch_component = { - item.name: branch - for branch in self.branches - for item in ( - branch.mass, - branch.piston, - branch.chamber, - branch.pipe, - branch.contact, - ) - } - pipe_flows: dict[str, object] = {} - for stage in solver._explicit_flow_plan: - pending: list[tuple[str, tuple[float, ...]]] = [] - for assignment in stage.assignments: - equation = equations.get(assignment.equation_id) - if equation is None: - raise ExactColumnsUnavailable("causalEquationMissing") - dependencies = [ - tangent(variable) - for variable in equation.variables - if variable != assignment.unknown.id - ] - if not any( - self._has_signal(vector) for vector in dependencies - ): - pending.append((assignment.unknown.id, zero)) - continue - if equation.relation == "sumToZero": - vectors = [ - tangent(variable) - for variable in equation.variables - if variable != assignment.unknown.id - and variable in solver._unknowns_by_id - and solver._unknowns_by_id[variable].role == "flow" - ] - pending.append( - (assignment.unknown.id, negative_sum(vectors)) - ) - continue - - component = assignment.component - model = getattr(component, "MODEL_TYPE", None) - if model == "amesim_pnl0001": - branch = branch_component.get(component.name) - if branch is None or component is not branch.pipe: - raise ExactColumnsUnavailable("unexpectedPipeReach") - flow_linearization = pipe_flows.get(component.name) - if flow_linearization is None: - properties = component.medium.properties_from_mU( - component.state.m, - component.state.U, - component.volume, - ) - flow_linearization = component.linearize_mass_flow( - component.port_1.p, - component.port_2.p, - properties.T, - ) - self._require_valid( - flow_linearization, - "pipeMassFlow", - ) - pipe_flows[component.name] = flow_linearization - vector = tuple( - flow_linearization.partial_p_1 * first - + flow_linearization.partial_p_2 * second - for first, second in zip( - tangent(f"{component.name}.port_1.p"), - tangent(f"{component.name}.port_2.p"), - strict=True, - ) - ) - elif model == "amesim_lstp00a": - contact_linearization = contacts.get(component.name) - if contact_linearization is None: - raise ExactColumnsUnavailable( - f"unexpectedContactReach:{component.name}" - ) - sign = ( - 1.0 - if assignment.unknown.port == "port_1" - else -1.0 - ) - vector = tuple( - sign * value - for value in contact_linearization.force_tangent - ) - elif model == "amesim_pnrp17": - piston_geometry = geometry.get(component.name) - if piston_geometry is None: - raise ExactColumnsUnavailable( - f"unexpectedPistonReach:{component.name}" - ) - other = next( - ( - tangent(variable) - for variable in equation.variables - if variable != assignment.unknown.id - and variable.endswith(".f") - ), - zero, - ) - sign = ( - -1.0 - if equation.id.endswith( - "piston_side_force_balance" - ) - else 1.0 - ) - vector = tuple( - -force + sign * pressure - for force, pressure in zip( - other, - piston_geometry.pressure_force_tangent, - strict=True, - ) - ) - else: - raise ExactColumnsUnavailable( - f"unsupportedReach:{model or 'connection'}" - ) - pending.append((assignment.unknown.id, tuple(vector))) - for key, vector in pending: - tangents[key] = vector - - outflow: dict[Endpoint, tuple[float, ...]] = {} - for branch in self.branches: - enthalpy_tangent = tuple( - chamber_properties[branch.chamber.name].tangents.h - ) - for port_name in branch.chamber.ports: - outflow[Endpoint(branch.chamber.name, port_name)] = ( - enthalpy_tangent - ) - # PNRP17 mirrors the connected chamber enthalpy on its sole - # pneumatic port at the already closed primal point. - outflow[Endpoint(branch.piston.name, "port_1")] = enthalpy_tangent - - connected_tangent: dict[ - str, - dict[str, tuple[float, ...]], - ] = {name: {} for name in self.system.network.components} - for connection in self.system.network.connections: - if connection.kind != "physical": - continue - first, second = connection.endpoints - connected_tangent[first.component][first.port] = outflow.get( - second, - zero, - ) - connected_tangent[second.component][second.port] = outflow.get( - first, - zero, - ) - - for component_name, port_values in connected_tangent.items(): - component = self.system.network.components[component_name] - if not getattr(component, "PRESSURE_FLOW_DEPENDS_ON_STREAM", False): - continue - if any( - self._has_signal(vector) for vector in port_values.values() - ): - raise ExactColumnsUnavailable( - f"streamSensitiveReach:{component_name}" - ) - - for branch in self.branches: - chamber = branch.chamber - chamber_linearization = chamber.linearize_state_derivative( - context.connected_h[chamber.name], - state_mass_tangent=zero, - state_energy_tangent=zero, - external_volume_tangent=( - geometry[branch.piston.name].volume_tangent - ), - external_volume_rate_tangent=( - geometry[branch.piston.name].volume_flow_tangent - ), - port_mass_flow_tangents={ - name: tangent(f"{chamber.name}.{name}.m_flow") - for name in chamber.ports - }, - connected_h_tangents=connected_tangent[chamber.name], - property_linearization=chamber_properties[chamber.name], - ) - self._require_valid(chamber_linearization, "chamberDerivative") - offset, size = self.state_offsets[chamber.name] - if size != 2: - raise ValueError("Target chamber state layout changed.") - out[offset, :], out[offset + 1, :] = ( - chamber_linearization.tangents - ) - - pipe = branch.pipe - pipe_properties = pipe.medium.linearize_properties_from_mU( - pipe.state.m, - pipe.state.U, - pipe.volume, - zero, - zero, - zero, - ) - self._require_valid(pipe_properties, "pipeProperties") - pipe_linearization = pipe.linearize_state_derivative( - context.connected_h[pipe.name], - state_mass_tangent=zero, - state_energy_tangent=zero, - port_mass_flow_tangents={ - name: tangent(f"{pipe.name}.{name}.m_flow") - for name in pipe.ports - }, - connected_h_tangents=connected_tangent[pipe.name], - property_linearization=pipe_properties, - ) - self._require_valid(pipe_linearization, "pipeDerivative") - offset, size = self.state_offsets[pipe.name] - if size != 2: - raise ValueError("Target pipe state layout changed.") - out[offset, :], out[offset + 1, :] = pipe_linearization.tangents - - target_pneumatic = { - item.name - for branch in self.branches - for item in (branch.chamber, branch.pipe) - } - for entry in self.system.mechanical_state_reducer.state_entries: - if ( - isinstance(entry, MechanicalConstraintGroup) - or entry.name in target_pneumatic - ): - continue - for port_name, port in entry.ports.items(): - definition = port.definition - if ( - definition is not None - and definition.kind == "physical" - and definition.domain == "pneumatic" - and self._has_signal( - tangent(f"{entry.name}.{port_name}.m_flow") - ) - ): - raise ExactColumnsUnavailable( - f"unsupportedDynamicReach:{entry.name}" - ) - - mass_seeds = { - branch.mass.name: ( - seeds[branch.velocity_index], - seeds[branch.position_index], - ) - for branch in self.branches - } - for group in self.system.mechanical_state_reducer.groups: - if len(group.components) != 1: - raise ExactColumnsUnavailable("reachableRigidMassGroup") - mass = group.representative - force_1 = tangent(f"{mass.name}.port_1.f") - force_2 = tangent(f"{mass.name}.port_2.f") - velocity, position = mass_seeds.get(mass.name, (zero, zero)) - if not any( - self._has_signal(vector) - for vector in (force_1, force_2, velocity, position) - ): - continue - fixed = ( - mass._constraint_acceleration == 0.0 - and mass._constraint_velocity == 0.0 - ) - if fixed and ( - abs(group.total_unconstrained_force()) - <= 1.0e-12 - * max( - abs(mass.port_1.f), - abs(mass.port_2.f), - 1.0, - ) - ): - raise ExactColumnsUnavailable("mechanicalReleaseBoundary") - mass_linearization = mass.linearize_state_derivative( - force_1, - force_2, - velocity, - position, - constraint_mode="current" if fixed else "free", - ) - self._require_valid( - mass_linearization, - "mechanicalDerivative", - ) - offset, size = self.state_offsets[mass.name] - if size != 2: - raise ValueError("Mechanical state layout changed.") - out[offset, :], out[offset + 1, :] = mass_linearization.tangents - - if not np.all(np.isfinite(out)): - raise ExactColumnsUnavailable("nonFiniteTangentColumns") - return out - - -@dataclass(frozen=True) -class _PistonBranchSpec: - names: tuple[str, str, str, str, str] - chamber_connection_port: str - - -def _compile_named_piston_tangent_provider( - system: "GenericFluidSystem", - branch_specs: Sequence[_PistonBranchSpec], -) -> ThreePistonTangentCompilation: - """Compile a named, topology-proven set of supported piston branches.""" - - solver = system.pressure_flow_solver - if not solver.causal_fast_path_eligible: - return _failed("causalFastPathIneligible") - if not solver.causal_fast_path_enabled: - return _failed("causalFastPathDisabled") - closure = system._thermofluid_closure_plan - if closure.uses_conservative_global_solver: - return _failed("conservativeThermofluidClosure") - if system.pneumatic_storage_reducer.groups: - return _failed("coupledPneumaticStorage") - - state_offsets: dict[str, tuple[int, int]] = {} - cursor = 0 - group_by_name: dict[str, MechanicalConstraintGroup] = {} - for entry in system.mechanical_state_reducer.state_entries: - if isinstance(entry, MechanicalConstraintGroup): - if len(entry.components) != 1: - cursor += 2 - continue - component = entry.representative - state_offsets[component.name] = (cursor, 2) - group_by_name[component.name] = entry - cursor += 2 - else: - state_offsets[entry.name] = (cursor, int(entry.state_size)) - cursor += int(entry.state_size) - - expected_types = ( - "amesim_mecmas21", - "amesim_pnrp17", - "amesim_pnch012", - "amesim_pnl0001", - "amesim_lstp00a", - ) - branches: list[ThreePistonBranch] = [] - for branch_spec in branch_specs: - names = branch_spec.names - try: - components = tuple(system.network.components[name] for name in names) - except KeyError: - return _failed("targetComponentMissing") - if tuple(getattr(item, "MODEL_TYPE", None) for item in components) != expected_types: - return _failed("targetComponentTypeMismatch") - mass, piston, chamber, pipe, contact = components - if mass.name not in group_by_name or mass.name not in state_offsets: - return _failed("targetMechanicalStateLayout") - offset, size = state_offsets[mass.name] - if size != 2: - return _failed("targetMechanicalStateLayout") - branches.append( - ThreePistonBranch( - mass=mass, - piston=piston, - chamber=chamber, - pipe=pipe, - contact=contact, - chamber_connection_port=( - branch_spec.chamber_connection_port - ), - velocity_index=offset, - position_index=offset + 1, - ) - ) - - required_pairs: set[frozenset[Endpoint]] = set() - for branch in branches: - required_pairs.update( - { - frozenset((Endpoint(branch.mass.name, "port_1"), Endpoint(branch.piston.name, "port_2"))), - frozenset( - ( - Endpoint(branch.piston.name, "port_1"), - Endpoint( - branch.chamber.name, - branch.chamber_connection_port, - ), - ) - ), - frozenset((Endpoint(branch.chamber.name, "port_1"), Endpoint(branch.pipe.name, "port_1"))), - frozenset((Endpoint(branch.piston.name, "port_5"), Endpoint(branch.contact.name, "port_1"))), - } - ) - actual_pairs = { - frozenset(connection.endpoints) - for connection in system.network.connections - if connection.kind == "physical" - } - if not required_pairs <= actual_pairs: - return _failed("targetTopologyMismatch") - - required_methods = ( - ("piston", "linearize_geometry_and_force"), - ("chamber", "linearize_state_derivative"), - ("pipe", "linearize_mass_flow"), - ("pipe", "linearize_state_derivative"), - ("contact", "linearize_contact_force"), - ("mass", "linearize_state_derivative"), - ) - for branch in branches: - for owner, method in required_methods: - if not callable(getattr(getattr(branch, owner), method, None)): - return _failed(f"missingTangentPrimitive:{owner}.{method}") - if not callable(getattr(branch.chamber.medium, "linearize_properties_from_mU", None)): - return _failed("missingTangentPrimitive:medium.linearize_properties_from_mU") - - if any( - len(group.components) != 1 - for group in system.mechanical_state_reducer.groups - ): - return _failed("rigidMassAggregation") - - target_mass_names = {branch.mass.name for branch in branches} - target_chamber_names = {branch.chamber.name for branch in branches} - target_pipe_names = {branch.pipe.name for branch in branches} - target_piston_names = {branch.piston.name for branch in branches} - target_contact_names = {branch.contact.name for branch in branches} - reached_ids: set[str] = set() - for variable in ("x", "v"): - for assignment in solver._causal_effort_plan_by_variable.get( - variable, - (), - ): - if any( - member.component in target_mass_names - for member in assignment.members - ): - reached_ids.update(member.id for member in assignment.members) - for assignment in solver._causal_effort_plan_by_variable.get("p", ()): - if any( - member.component in target_chamber_names - for member in assignment.members - ): - reached_ids.update(member.id for member in assignment.members) - - equations = { - item.id: item for item in solver.equation_templates - } - reached_assignments = [] - allowed_reached_models = { - "amesim_pnl0001", - "amesim_pnrp17", - "amesim_lstp00a", - } - for stage in solver._explicit_flow_plan: - stage_reached = [] - for assignment in stage.assignments: - equation = equations.get(assignment.equation_id) - if equation is None: - return _failed("causalEquationMissing") - dependencies = set(equation.variables) - { - assignment.unknown.id - } - if not dependencies.intersection(reached_ids): - continue - component = assignment.component - if equation.relation != "sumToZero": - model_type = getattr(component, "MODEL_TYPE", None) - if model_type not in allowed_reached_models: - return _failed(f"unsupportedReach:{model_type}") - expected_names = { - "amesim_pnl0001": target_pipe_names, - "amesim_pnrp17": target_piston_names, - "amesim_lstp00a": target_contact_names, - }[model_type] - if component.name not in expected_names: - return _failed( - f"unexpectedReach:{model_type}:{component.name}" - ) - if bool( - getattr( - component, - "PRESSURE_FLOW_DEPENDS_ON_STREAM", - False, - ) - ): - return _failed(f"streamSensitiveReach:{component.name}") - stage_reached.append(assignment) - reached_assignments.extend(stage_reached) - reached_ids.update(item.unknown.id for item in stage_reached) - - # The only non-zero h_outflow seeds are the target PNCH ports. Each - # direct neighbour must consume it in a supported target balance, mirror - # it through the one-port piston, or terminate at a fixed PNPL01 cap. - # Secondary pressure blocks may contain the same causal flow coordinates, - # so membership alone is not evidence of a stream derivative. The direct - # enthalpy reach proof below, plus the runtime dynamic-owner gate, is the - # relevant condition for this proof-gated branch program. - neighbor_by_endpoint: dict[Endpoint, Endpoint] = {} - for connection in system.network.connections: - if connection.kind != "physical": - continue - first, second = connection.endpoints - neighbor_by_endpoint[first] = second - neighbor_by_endpoint[second] = first - permitted_h_neighbors = { - "amesim_pnl0001", - "amesim_pnrp17", - "amesim_pnpl01", - } - for branch in branches: - for port_name in branch.chamber.ports: - neighbor = neighbor_by_endpoint.get( - Endpoint(branch.chamber.name, port_name) - ) - if neighbor is None: - return _failed("targetStreamBindingMissing") - component = system.network.components[neighbor.component] - if ( - getattr(component, "MODEL_TYPE", None) - not in permitted_h_neighbors - ): - return _failed( - f"unsupportedStreamReach:{component.name}" - ) - if bool( - getattr( - component, - "PRESSURE_FLOW_DEPENDS_ON_STREAM", - False, - ) - ): - return _failed(f"streamSensitiveReach:{component.name}") - - columns = tuple( - sorted( - index - for branch in branches - for index in (branch.velocity_index, branch.position_index) - ) - ) - provider = ThreePistonTangentProvider( - system, - tuple(branches), - columns, - state_offsets, - reached_assignment_count=len(reached_assignments), - ) - return ThreePistonTangentCompilation( - True, - None, - columns, - provider, - reached_assignment_count=len(reached_assignments), - ) - - -def _physical_neighbor_map( - system: "GenericFluidSystem", -) -> dict[Endpoint, Endpoint]: - """Return the one-to-one physical connector map proved by the network.""" - - neighbors: dict[Endpoint, Endpoint] = {} - for connection in system.network.connections: - if connection.kind != "physical": - continue - first, second = connection.endpoints - # SimulationNetwork already rejects multiply connected physical ports. - # Retain a defensive gate because this compiler may also be called by - # custom network builders in tests or downstream applications. - if first in neighbors or second in neighbors: - raise ValueError("Physical endpoint has more than one connection.") - neighbors[first] = second - neighbors[second] = first - return neighbors - - -def _discover_supported_piston_branch_specs( - system: "GenericFluidSystem", -) -> tuple[_PistonBranchSpec, ...] | ThreePistonTangentCompilation: - """Discover every complete catalog piston branch by type and port topology. - - A PNRP17 is the unambiguous root: its mechanical piston-side port must be - driven by a singleton MECMAS21 coordinate, its pneumatic port must feed a - PNCH012 whose first port feeds PNL0001, and its rod-side port must meet an - LSTP00A contact. If even one PNRP17 is only partially supported, reject the - batch with a stable reason instead of silently omitting derivative columns. - """ - - try: - neighbors = _physical_neighbor_map(system) - except ValueError: - return _failed("unsupportedPistonBranchTopology:multipleConnection") - - components = system.network.components - - def model_type(endpoint: Endpoint | None) -> str | None: - if endpoint is None: - return None - return getattr(components[endpoint.component], "MODEL_TYPE", None) - - pistons = tuple( - component - for component in components.values() - if getattr(component, "MODEL_TYPE", None) == "amesim_pnrp17" - ) - if not pistons: - return _failed("supportedPistonBranchMissing") - - specs: list[_PistonBranchSpec] = [] - for piston in pistons: - mass_endpoint = neighbors.get(Endpoint(piston.name, "port_2")) - if ( - model_type(mass_endpoint) != "amesim_mecmas21" - or mass_endpoint is None - or mass_endpoint.port != "port_1" - ): - return _failed("unsupportedPistonBranchTopology:mass") - - chamber_endpoint = neighbors.get(Endpoint(piston.name, "port_1")) - if model_type(chamber_endpoint) != "amesim_pnch012": - return _failed("unsupportedPistonBranchTopology:chamber") - assert chamber_endpoint is not None - - pipe_endpoint = neighbors.get( - Endpoint(chamber_endpoint.component, "port_1") - ) - if ( - model_type(pipe_endpoint) != "amesim_pnl0001" - or pipe_endpoint is None - or pipe_endpoint.port != "port_1" - ): - return _failed("unsupportedPistonBranchTopology:pipe") - - contact_endpoint = neighbors.get(Endpoint(piston.name, "port_5")) - if ( - model_type(contact_endpoint) != "amesim_lstp00a" - or contact_endpoint is None - or contact_endpoint.port != "port_1" - ): - return _failed("unsupportedPistonBranchTopology:contact") - - specs.append( - _PistonBranchSpec( - names=( - mass_endpoint.component, - piston.name, - chamber_endpoint.component, - pipe_endpoint.component, - contact_endpoint.component, - ), - chamber_connection_port=chamber_endpoint.port, - ) - ) - - # Port uniqueness already proves unique pistons and masses, but explicitly - # reject a custom multi-port chamber/contact/pipe shared by two roots. The - # tangent propagation assumes one geometry seed per selected state owner. - for role_index in range(5): - if len({spec.names[role_index] for spec in specs}) != len(specs): - return _failed("unsupportedPistonBranchTopology:sharedComponent") - return tuple(specs) - - -def compile_supported_piston_tangent_provider( - system: "GenericFluidSystem", -) -> ThreePistonTangentCompilation: - """Compile all name-independent, topology-supported piston branches.""" - - discovered = _discover_supported_piston_branch_specs(system) - if isinstance(discovered, ThreePistonTangentCompilation): - return discovered - return _compile_named_piston_tangent_provider(system, discovered) - - -def compile_three_piston_tangent_provider( - system: "GenericFluidSystem", -) -> ThreePistonTangentCompilation: - """Compile the committed legacy three-piston target by its stable names.""" - - return _compile_named_piston_tangent_provider( - system, - tuple( - _PistonBranchSpec( - names=names, - chamber_connection_port="port_3", - ) - for names in _TARGET_BRANCH_NAMES - ), - ) diff --git a/app/simulation/solvers/thermofluid.py b/app/simulation/solvers/thermofluid.py deleted file mode 100644 index d2f028e..0000000 --- a/app/simulation/solvers/thermofluid.py +++ /dev/null @@ -1,567 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Sequence -from copy import copy -from dataclasses import dataclass, replace - -from app.simulation.core.errors import RecoverableTrialStateError -from app.simulation.core.ports import PortState - - -_STREAM_CACHE_ATTRIBUTE_NAMES = frozenset( - { - "_connected_h", - "temperature_reference_h", - } -) - - -def _is_stream_cache_attribute(name: str) -> bool: - """Return whether an attribute belongs to the stream/temperature replay state. - - Catalog components currently use ``_connected_h`` and - ``temperature_reference_h``. The name-based extension keeps conservative - third-party caches recoverable without copying an entire component graph. - Components with opaque cache names can provide the explicit hooks documented - by :class:`ThermofluidTransactionPlan`. - """ - - lowered = name.lower() - return ( - name in _STREAM_CACHE_ATTRIBUTE_NAMES - or lowered.startswith("_stream_") - or "connected_h" in lowered - or "connected_enthalpy" in lowered - or "temperature_reference" in lowered - ) - - -def _copy_cache_value(value: object) -> object: - """Shallow-copy a stream cache without traversing the component graph.""" - - if isinstance(value, (dict, list, set, bytearray)): - return copy(value) - return value - - -@dataclass(frozen=True) -class ThermofluidWorstPort: - component: str - port: str - value: float - signed_delta: float - - def as_dict(self) -> dict[str, object]: - return { - "component": self.component, - "port": self.port, - "value": self.value, - "signedDelta": self.signed_delta, - } - - -@dataclass(frozen=True) -class ThermofluidIterationDelta: - iteration: int - max_delta: float - scale: float - tolerance: float - worst_port: ThermofluidWorstPort | None - - def as_dict(self) -> dict[str, object]: - return { - "iteration": self.iteration, - "maxDelta": self.max_delta, - "scale": self.scale, - "tolerance": self.tolerance, - "worstPort": ( - self.worst_port.as_dict() - if self.worst_port is not None - else None - ), - } - - -@dataclass(frozen=True) -class ThermofluidClosureSuccess: - rhs_time: float - iterations: int - max_delta: float - scale: float - tolerance: float - worst_port: ThermofluidWorstPort | None - - @classmethod - def from_iteration( - cls, - rhs_time: float, - delta: ThermofluidIterationDelta, - ) -> ThermofluidClosureSuccess: - return cls( - rhs_time=float(rhs_time), - iterations=delta.iteration, - max_delta=delta.max_delta, - scale=delta.scale, - tolerance=delta.tolerance, - worst_port=delta.worst_port, - ) - - def as_dict(self) -> dict[str, object]: - return { - "rhsTime": self.rhs_time, - "iterations": self.iterations, - "maxDelta": self.max_delta, - "scale": self.scale, - "tolerance": self.tolerance, - "worstPort": ( - self.worst_port.as_dict() - if self.worst_port is not None - else None - ), - } - - -@dataclass(frozen=True) -class ThermofluidClosureFailure: - failed_rhs_time: float - iterations: int - delta_tail: tuple[ThermofluidIterationDelta, ...] - max_delta: float - scale: float - tolerance: float - worst_port: ThermofluidWorstPort | None - failure_count: int = 0 - - @classmethod - def from_iterations( - cls, - failed_rhs_time: float, - deltas: Sequence[ThermofluidIterationDelta], - *, - tail_limit: int = 8, - ) -> ThermofluidClosureFailure: - if not deltas: - raise ValueError("A thermofluid failure requires iteration diagnostics.") - final = deltas[-1] - return cls( - failed_rhs_time=float(failed_rhs_time), - iterations=final.iteration, - delta_tail=tuple(deltas[-tail_limit:]), - max_delta=final.max_delta, - scale=final.scale, - tolerance=final.tolerance, - worst_port=final.worst_port, - ) - - def as_dict(self) -> dict[str, object]: - return { - "failedRhsTime": self.failed_rhs_time, - "iterations": self.iterations, - "deltaTail": [item.as_dict() for item in self.delta_tail], - "maxDelta": self.max_delta, - "scale": self.scale, - "tolerance": self.tolerance, - "worstPort": ( - self.worst_port.as_dict() - if self.worst_port is not None - else None - ), - "failureCount": self.failure_count, - } - - -class ThermofluidClosureError(RecoverableTrialStateError): - """Recoverable exhaustion of the stream/pressure-flow fixed point. - - Stream propagation failures and algebraic-solver failures intentionally - retain their original exception types: rollback is still applied, but a - smaller ODE step is not known to repair those structural/numerical errors. - """ - - def __init__(self, diagnostics: ThermofluidClosureFailure) -> None: - super().__init__( - "Stream enthalpy and pressure-flow coupling did not converge " - f"after {diagnostics.iterations} iterations at " - f"t={diagnostics.failed_rhs_time:.17g}." - ) - self.diagnostics = diagnostics - - -class ThermofluidClosureDiagnostics: - """Run-level RHS outcomes; maintenance/postprocessing calls do not write it.""" - - def __init__(self) -> None: - self.failure_count = 0 - self.last_failure: ThermofluidClosureFailure | None = None - self.last_success: ThermofluidClosureSuccess | None = None - - def record_success(self, success: ThermofluidClosureSuccess) -> None: - self.last_success = success - - def record_failure( - self, - failure: ThermofluidClosureFailure, - ) -> ThermofluidClosureFailure: - self.failure_count += 1 - recorded = replace(failure, failure_count=self.failure_count) - self.last_failure = recorded - return recorded - - def as_dict(self) -> dict[str, object]: - return { - "failureCount": self.failure_count, - "lastFailure": ( - self.last_failure.as_dict() - if self.last_failure is not None - else None - ), - "lastSuccess": ( - self.last_success.as_dict() - if self.last_success is not None - else None - ), - } - - -@dataclass(frozen=True) -class _PortValueBinding: - component_name: str - port_name: str - state: PortState - variable: str - - -@dataclass(frozen=True) -class _PortFieldPlan: - variable: str - states: tuple[PortState, ...] - - -@dataclass(frozen=True) -class _FlowBinding: - component_name: str - port_name: str - state: PortState - - -@dataclass(frozen=True) -class _ComponentCacheBinding: - component: object - attribute_names: tuple[str, ...] - attribute_name_set: frozenset[str] - snapshot_hook: Callable[[], object] | None - restore_hook: Callable[[object], None] | None - - -@dataclass -class ThermofluidTransactionSnapshot: - plan: ThermofluidTransactionPlan - port_values: tuple[list[float], ...] - component_cache_values: tuple[list[object], ...] - custom_cache_values: list[object | None] - diagnostic_values: list[object] - - def restore(self) -> None: - plan = self.plan - plan._restore_port_values(self.port_values) - - for binding, values, custom_value in zip( - plan.component_cache_bindings, - self.component_cache_values, - self.custom_cache_values, - ): - component = binding.component - for name in tuple(getattr(component, "__dict__", {})): - if ( - name.startswith("_causal_") - or _is_stream_cache_attribute(name) - ) and name not in binding.attribute_name_set: - delattr(component, name) - for name, value in zip(binding.attribute_names, values): - setattr(component, name, _copy_cache_value(value)) - if binding.restore_hook is not None: - binding.restore_hook(custom_value) - - for owner, value in zip( - plan.diagnostic_owners, - self.diagnostic_values, - ): - owner.last_diagnostics = value - - -class ThermofluidTransactionPlan: - """Compiled, lightweight rollback boundary for one Generic RHS closure. - - It snapshots active physical-port values, catalog stream-temperature caches, - component ``_causal_*`` seed fields, and resolver/solver last diagnostics. - A custom stream-aware component with an opaque mutable cache can implement - both ``snapshot_thermofluid_closure_cache()`` and - ``restore_thermofluid_closure_cache(snapshot)``; these hooks are invoked in - addition to the standard name-based cache capture. - """ - - def __init__( - self, - *, - port_value_bindings: tuple[_PortValueBinding, ...], - port_field_plans: tuple[_PortFieldPlan, ...], - flow_bindings: tuple[_FlowBinding, ...], - component_cache_bindings: tuple[_ComponentCacheBinding, ...], - component_count: int, - diagnostic_owners: tuple[object, ...], - ) -> None: - self.port_value_bindings = port_value_bindings - self.port_field_plans = port_field_plans - self.flow_bindings = flow_bindings - self.component_cache_bindings = component_cache_bindings - self.component_count = component_count - self.diagnostic_owners = diagnostic_owners - self._snapshot = ThermofluidTransactionSnapshot( - plan=self, - port_values=tuple( - [0.0] * len(field.states) - for field in port_field_plans - ), - component_cache_values=tuple( - [None] * len(binding.attribute_names) - for binding in component_cache_bindings - ), - custom_cache_values=[None] * len(component_cache_bindings), - diagnostic_values=[None] * len(diagnostic_owners), - ) - - @classmethod - def compile( - cls, - network: object, - *, - diagnostic_owners: Sequence[object] = (), - ) -> ThermofluidTransactionPlan: - components = tuple(getattr(network, "components").values()) - port_value_bindings: list[_PortValueBinding] = [] - port_states_by_variable: dict[str, list[PortState]] = {} - flow_bindings: list[_FlowBinding] = [] - component_cache_bindings: list[_ComponentCacheBinding] = [] - for component in components: - active_definitions = tuple( - definition - for definition in component.active_port_definitions - if definition.kind == "physical" - ) - for definition in active_definitions: - state = component.get_port(definition.name) - flow_bindings.append( - _FlowBinding(component.name, definition.name, state) - ) - for variable in definition.variables: - port_states_by_variable.setdefault(variable.name, []).append(state) - port_value_bindings.append( - _PortValueBinding( - component.name, - definition.name, - state, - variable.name, - ) - ) - - attribute_names = tuple( - name - for name in getattr(component, "__dict__", {}) - if name.startswith("_causal_") - or _is_stream_cache_attribute(name) - ) - snapshot_hook = getattr( - component, - "snapshot_thermofluid_closure_cache", - None, - ) - restore_hook = getattr( - component, - "restore_thermofluid_closure_cache", - None, - ) - hooks_are_available = callable(snapshot_hook) and callable(restore_hook) - if attribute_names or hooks_are_available: - component_cache_bindings.append( - _ComponentCacheBinding( - component=component, - attribute_names=attribute_names, - attribute_name_set=frozenset(attribute_names), - snapshot_hook=(snapshot_hook if hooks_are_available else None), - restore_hook=(restore_hook if hooks_are_available else None), - ) - ) - - owners = tuple( - dict.fromkeys( - owner - for owner in diagnostic_owners - if hasattr(owner, "last_diagnostics") - ) - ) - return cls( - port_value_bindings=tuple(port_value_bindings), - port_field_plans=tuple( - _PortFieldPlan(variable, tuple(states)) - for variable, states in port_states_by_variable.items() - ), - flow_bindings=tuple(flow_bindings), - component_cache_bindings=tuple(component_cache_bindings), - component_count=len(components), - diagnostic_owners=owners, - ) - - def capture(self) -> ThermofluidTransactionSnapshot: - # GenericFluidSystem executes one RHS serially. Reuse one compiled - # workspace rather than allocating a snapshot object and several outer - # tuples at every successful trial point. - snapshot = self._snapshot - self._capture_port_values(snapshot.port_values) - for binding, values in zip( - self.component_cache_bindings, - snapshot.component_cache_values, - ): - for position, name in enumerate(binding.attribute_names): - values[position] = _copy_cache_value( - getattr(binding.component, name) - ) - for position, binding in enumerate(self.component_cache_bindings): - snapshot.custom_cache_values[position] = ( - binding.snapshot_hook() - if binding.snapshot_hook is not None - else None - ) - for position, owner in enumerate(self.diagnostic_owners): - snapshot.diagnostic_values[position] = owner.last_diagnostics - return snapshot - - def _capture_port_values( - self, - workspaces: tuple[list[float], ...], - ) -> None: - for field, values in zip(self.port_field_plans, workspaces): - variable = field.variable - states = field.states - if variable == "p": - for position, state in enumerate(states): - values[position] = state.p - elif variable == "m_flow": - for position, state in enumerate(states): - values[position] = state.m_flow - elif variable == "h_outflow": - for position, state in enumerate(states): - values[position] = state.h_outflow - elif variable == "volume": - for position, state in enumerate(states): - values[position] = state.volume - elif variable == "volume_flow": - for position, state in enumerate(states): - values[position] = state.volume_flow - elif variable == "x": - for position, state in enumerate(states): - values[position] = state.x - elif variable == "v": - for position, state in enumerate(states): - values[position] = state.v - elif variable == "f": - for position, state in enumerate(states): - values[position] = state.f - else: - for position, state in enumerate(states): - values[position] = getattr(state, variable) - - def _restore_port_values( - self, - workspaces: tuple[list[float], ...], - ) -> None: - for field, values in zip(self.port_field_plans, workspaces): - variable = field.variable - states = field.states - if variable == "p": - for state, value in zip(states, values): - state.p = value - elif variable == "m_flow": - for state, value in zip(states, values): - state.m_flow = value - elif variable == "h_outflow": - for state, value in zip(states, values): - state.h_outflow = value - elif variable == "volume": - for state, value in zip(states, values): - state.volume = value - elif variable == "volume_flow": - for state, value in zip(states, values): - state.volume_flow = value - elif variable == "x": - for state, value in zip(states, values): - state.x = value - elif variable == "v": - for state, value in zip(states, values): - state.v = value - elif variable == "f": - for state, value in zip(states, values): - state.f = value - else: - for state, value in zip(states, values): - setattr(state, variable, value) - - def flow_values(self) -> tuple[float, ...]: - return tuple(float(binding.state.m_flow) for binding in self.flow_bindings) - - def measure_flow_delta( - self, - previous: Sequence[float], - *, - iteration: int, - relative_tolerance: float, - ) -> ThermofluidIterationDelta: - current = self.flow_values() - scale = max( - (abs(value) for value in (*previous, *current)), - default=1.0, - ) - scale = max(scale, 1.0) - worst_index = -1 - worst_signed_delta = 0.0 - max_delta = 0.0 - for index, (old, new) in enumerate(zip(previous, current)): - signed_delta = new - old - magnitude = abs(signed_delta) - if magnitude > max_delta: - worst_index = index - worst_signed_delta = signed_delta - max_delta = magnitude - worst_port = None - if worst_index >= 0: - binding = self.flow_bindings[worst_index] - worst_port = ThermofluidWorstPort( - component=binding.component_name, - port=binding.port_name, - value=current[worst_index], - signed_delta=worst_signed_delta, - ) - return ThermofluidIterationDelta( - iteration=int(iteration), - max_delta=max_delta, - scale=scale, - tolerance=float(relative_tolerance) * scale, - worst_port=worst_port, - ) - - def diagnostics(self) -> dict[str, int]: - stream_cache_slot_count = sum( - len(binding.attribute_names) - for binding in self.component_cache_bindings - ) - return { - "physicalPortValueSlotCount": len(self.port_value_bindings), - "physicalFlowPortCount": len(self.flow_bindings), - "componentCount": self.component_count, - "cacheBindingCount": len(self.component_cache_bindings), - "streamAndCausalCacheSlotCount": stream_cache_slot_count, - "customCacheHookCount": sum( - binding.snapshot_hook is not None - for binding in self.component_cache_bindings - ), - "diagnosticOwnerCount": len(self.diagnostic_owners), - } diff --git a/app/simulation/systems/generic.py b/app/simulation/systems/generic.py deleted file mode 100644 index e218766..0000000 --- a/app/simulation/systems/generic.py +++ /dev/null @@ -1,1824 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass, replace -from math import floor, isfinite -import os -from sys import maxsize -from typing import Literal - -from app.simulation.core.base import Component, DynamicComponent -from app.simulation.core.metadata import ResultVariableMetadata -from app.simulation.core.ports import PortState -from app.simulation.performance import performance_span, profile_phase -from app.simulation.property_cache import with_property_cache -from app.simulation.solvers.algebraic import PressureFlowSolver -from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver -from app.simulation.solvers.jacobian import ( - SparseJacobianCompatibilityError, - SparseSecantJacobian, -) -from app.simulation.solvers.mechanical import ( - MechanicalConstraintGroup, - MechanicalStateReducer, -) -from app.simulation.solvers.pneumatic_storage import ( - IdealPneumaticStorageReducer, - ideal_storage_group_is_reducible, -) -from app.simulation.solvers.pneumatic_volume import PneumaticVolumeResolver -from app.simulation.solvers.solver import ( - IntegrationCancelled, - ODESolution, - SolveIVPConfig, - SolverActivityTracker, - integrate_ode, -) -from app.simulation.solvers.signal import SignalResolver -from app.simulation.solvers.stream import StreamResolver -from app.simulation.solvers.tangent import ( - ThreePistonTangentCompilation, - ThreePistonTangentProvider, - compile_supported_piston_tangent_provider, -) -from app.simulation.solvers.thermofluid import ( - ThermofluidClosureDiagnostics, - ThermofluidClosureError, - ThermofluidClosureFailure, - ThermofluidClosureSuccess, - ThermofluidTransactionPlan, -) -from app.simulation.systems.network import Endpoint, SimulationNetwork - - -SimulationProgressCallback = Callable[[float, str], None] -SimulationCancellationCheck = Callable[[], bool] -SimulationRunStatus = Literal["completed", "cancelled", "failed"] -ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE = "SIMULATION_ODE_JACOBIAN_MODE" - - -def _requested_ode_jacobian_mode() -> Literal[ - "optimized", - "hybrid", - "semi-analytic", - "scipy", -]: - value = os.getenv( - ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE, - "scipy", - ).strip().lower() - if value in {"optimized", "colored"}: - return "optimized" - if value in {"hybrid", "secant"}: - return "hybrid" - if value in {"semi-analytic", "semi_analytic", "analytic"}: - return "semi-analytic" - if value in { - "scipy", - "native", - "finite-difference", - "0", - "false", - "no", - "off", - }: - return "scipy" - raise ValueError( - f"{ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE} must be " - "'optimized', 'hybrid', 'semi-analytic', or 'scipy'." - ) - - -@dataclass(frozen=True) -class _ThermofluidClosurePlan: - """Static execution data for one compiled network. - - The first pressure-flow solve remains global. Later fixed-point passes only - need the physical islands whose constitutive equations read stream-derived - enthalpy. An unclassified custom stream component deliberately falls back - to the original global solve. - """ - - physical_ports: tuple[PortState, ...] - global_component_group: tuple[str, ...] - secondary_pressure_solvers: tuple[PressureFlowSolver, ...] - secondary_component_groups: tuple[tuple[str, ...], ...] - uses_conservative_global_solver: bool - conservative_fallback_reason: str | None - secondary_block_solvers: tuple[StreamPressureBlockSolver, ...] = () - - -@dataclass(frozen=True) -class SimulationPreparationIssue: - code: str - message: str - - def as_dict(self) -> dict[str, str]: - return {"code": self.code, "message": self.message} - - -class SimulationPreparationError(ValueError): - def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None: - super().__init__("The compiled model is not ready for simulation.") - self.issues = issues - - -class SimulationSampleTimeError(ValueError): - """Stable failure contract for an unsafe or unrepresentable sample grid.""" - - def __init__(self, code: str, message: str) -> None: - super().__init__(message) - self.code = code - - -@dataclass(frozen=True) -class GenericSimulationResult: - success: bool - status: SimulationRunStatus - message: str - simulated_until: float - requested_stop_time: float - variables: tuple[ResultVariableMetadata, ...] - series: dict[str, list[float]] - final: dict[str, float] - diagnostics: dict[str, object] - - def as_dict(self) -> dict[str, object]: - return { - "success": self.success, - "status": self.status, - "partial": self.status != "completed", - "message": self.message, - "simulatedUntil": self.simulated_until, - "requestedStopTime": self.requested_stop_time, - "variables": [variable.as_dict() for variable in self.variables], - "series": self.series, - "final": self.final, - "diagnostics": self.diagnostics, - } - - -class _UnionFind: - def __init__(self, items: set[Endpoint]) -> None: - self.parent = {item: item for item in items} - - def find(self, item: Endpoint) -> Endpoint: - parent = self.parent[item] - if parent != item: - self.parent[item] = self.find(parent) - return self.parent[item] - - def union(self, first: Endpoint, second: Endpoint) -> None: - first_root = self.find(first) - second_root = self.find(second) - if first_root != second_root: - self.parent[second_root] = first_root - - -def _equation_port(component_name: str, variable: str) -> Endpoint | None: - parts = variable.rsplit(".", 2) - if len(parts) != 3: - return None - prefix, port_name, variable_name = parts - if prefix != component_name or variable_name != "p": - return None - return Endpoint(component_name, port_name) - - -def simulation_preparation_issues( - network: SimulationNetwork, -) -> tuple[SimulationPreparationIssue, ...]: - issues: list[SimulationPreparationIssue] = [] - physical_endpoints = { - Endpoint(component.name, port_name) - for component in network.components.values() - for port_name in component.required_connection_ports - } - connected_endpoints = { - endpoint - for connection in network.connections - if connection.kind == "physical" - for endpoint in connection.endpoints - } - for endpoint in sorted(physical_endpoints - connected_endpoints, key=str): - issues.append( - SimulationPreparationIssue( - "PORT_UNCONNECTED", - f"Physical port {endpoint} must be connected before simulation.", - ) - ) - - structure = network.pressure_flow_structure_dict() - if not structure["isSquare"]: - issues.append( - SimulationPreparationIssue( - "PRESSURE_FLOW_SYSTEM_NOT_SQUARE", - "Pressure-flow equation count does not match the unknown count: " - f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.", - ) - ) - - dynamic_names = { - component.name - for component in network.components.values() - if isinstance(component, DynamicComponent) - } - if not dynamic_names: - issues.append( - SimulationPreparationIssue( - "DYNAMIC_STATE_MISSING", - "Each simulated network requires at least one storage component.", - ) - ) - - physical_component_names = { - component.name - for component in network.components.values() - if any( - definition.kind == "physical" - for definition in component.active_port_definitions - ) - } - adjacency = {name: set() for name in physical_component_names} - for connection in network.connections: - if connection.kind != "physical": - continue - first, second = connection.endpoints - adjacency[first.component].add(second.component) - adjacency[second.component].add(first.component) - remaining = set(adjacency) - while remaining: - start = remaining.pop() - group = {start} - stack = [start] - while stack: - current = stack.pop() - for neighbour in adjacency[current] - group: - group.add(neighbour) - remaining.discard(neighbour) - stack.append(neighbour) - if not (group & dynamic_names): - issues.append( - SimulationPreparationIssue( - "ALGEBRAIC_ISLAND_HAS_NO_STORAGE", - "A connected physical network has no pressure/enthalpy storage anchor: " - + ", ".join(sorted(group)) - + ".", - ) - ) - - if physical_endpoints: - effort_groups = _UnionFind(physical_endpoints) - for connection in network.connections: - if connection.kind == "physical": - effort_groups.union(*connection.endpoints) - storage_ports: dict[Endpoint, str] = {} - for component in network.components.values(): - for equation in component.pressure_flow_equation_residuals(): - pressure_ports = [ - endpoint - for variable in equation.variables - if (endpoint := _equation_port(component.name, variable)) is not None - ] - if equation.relation == "equal" and len(pressure_ports) == 2: - effort_groups.union(pressure_ports[0], pressure_ports[1]) - if equation.relation == "state": - for endpoint in pressure_ports: - storage_ports[endpoint] = component.name - - storages_by_group: dict[Endpoint, dict[Endpoint, str]] = {} - for endpoint, component_name in storage_ports.items(): - storages_by_group.setdefault(effort_groups.find(endpoint), {})[ - endpoint - ] = component_name - for storage_endpoints in storages_by_group.values(): - storage_names = set(storage_endpoints.values()) - if len(storage_names) > 1: - if ideal_storage_group_is_reducible( - network, - storage_endpoints, - ): - continue - issues.append( - SimulationPreparationIssue( - "IDEAL_STORAGE_COUPLING_UNSUPPORTED", - "Storage components are connected without a resistance: " - + ", ".join(sorted(storage_names)) - + ". Insert an orifice or pipe between them.", - ) - ) - - return tuple(issues) - - -def simulation_sample_times( - config: SolveIVPConfig, - step: float, -) -> list[float]: - t_start = float(config.t_start) - t_stop = float(config.t_stop) - if not isfinite(t_start) or not isfinite(t_stop): - raise SimulationSampleTimeError( - "SIMULATION_VALUE_NOT_FINITE", - "Simulation start and stop times must be finite.", - ) - if step <= 0.0 or not isfinite(step): - raise SimulationSampleTimeError( - "SIMULATION_SAMPLE_STEP_INVALID", - "Simulation sample step must be finite and greater than zero.", - ) - duration = t_stop - t_start - if not isfinite(duration): - raise SimulationSampleTimeError( - "SIMULATION_TIME_SPAN_NOT_FINITE", - "Simulation time span must be finite.", - ) - if duration <= 0.0: - raise SimulationSampleTimeError( - "SIMULATION_TIME_RANGE_INVALID", - "Simulation stop time must be greater than start time.", - ) - - ratio = duration / step - if not isfinite(ratio): - raise SimulationSampleTimeError( - "SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE", - "Simulation sample count cannot be represented by this runtime; " - "increase sampleStep.", - ) - interval_count = floor(ratio) - # There is no product-level point cap. Still reject a collection that the - # Python runtime cannot index before multiplying by the potentially huge - # interval count or allocating the output grid. - if interval_count > maxsize - 2: - raise SimulationSampleTimeError( - "SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE", - "Simulation sample count cannot be represented by this runtime; " - "increase sampleStep.", - ) - last_regular_time = t_start + interval_count * step - append_stop = last_regular_time < t_stop - requested_point_count = interval_count + 1 + int(append_stop) - if requested_point_count > maxsize: - raise SimulationSampleTimeError( - "SIMULATION_SAMPLE_COUNT_UNREPRESENTABLE", - "Simulation sample count cannot be represented by this runtime; " - "increase sampleStep.", - ) - - times = [t_start] - for index in range(1, interval_count + 1): - candidate = t_start + index * step - if not isfinite(candidate): - raise SimulationSampleTimeError( - "SIMULATION_SAMPLE_TIME_UNREPRESENTABLE", - "Simulation sampleStep cannot be represented over the requested " - "absolute time range.", - ) - if candidate >= t_stop: - candidate = t_stop - if candidate <= times[-1]: - raise SimulationSampleTimeError( - "SIMULATION_SAMPLE_TIME_UNREPRESENTABLE", - "Simulation sampleStep is too small to advance floating-point " - "time over the requested absolute time range.", - ) - times.append(candidate) - if candidate == t_stop: - break - if times[-1] < t_stop: - times.append(t_stop) - - if len(times) < 2 or any( - current >= following - for current, following in zip(times, times[1:]) - ): - raise SimulationSampleTimeError( - "SIMULATION_SAMPLE_TIME_UNREPRESENTABLE", - "Simulation sample times must contain at least two strictly " - "increasing values.", - ) - return times - - -class GenericFluidSystem: - """Topology-driven, semi-explicit fluid simulation for registered components.""" - - @profile_phase("simulation.system_construction") - def __init__(self, network: SimulationNetwork) -> None: - issues = simulation_preparation_issues(network) - if issues: - raise SimulationPreparationError(issues) - self.network = network - self.dynamic_components = network.dynamic_components() - self.mechanical_state_reducer = MechanicalStateReducer( - network, - self.dynamic_components, - ) - self.pneumatic_storage_reducer = IdealPneumaticStorageReducer( - network, - self.mechanical_state_reducer, - ) - self.pressure_flow_solver = PressureFlowSolver(network) - self.pneumatic_volume_resolver = PneumaticVolumeResolver(network) - self.signal_resolver = SignalResolver(network) - self.stream_resolver = StreamResolver(network) - self._thermofluid_closure_plan = self._build_thermofluid_closure_plan() - self._thermofluid_transaction_plan = ThermofluidTransactionPlan.compile( - network, - diagnostic_owners=( - self.signal_resolver, - self.pneumatic_volume_resolver, - self.stream_resolver, - self.pressure_flow_solver, - *self._thermofluid_closure_plan.secondary_pressure_solvers, - ), - ) - self._thermofluid_closure_diagnostics = ThermofluidClosureDiagnostics() - self.algebraic_solve_count = 0 - self.algebraic_seeded_solve_count = 0 - self.algebraic_nonlinear_solve_count = 0 - self.algebraic_optimizer_evaluation_count = 0 - self.algebraic_residual_evaluation_count = 0 - self.algebraic_block_fallback_count = 0 - self.algebraic_dense_fallback_count = 0 - self.thermofluid_pressure_pass_count = 0 - self.max_algebraic_residual = 0.0 - self.max_algebraic_evaluations = 0 - self.max_algebraic_residual_evaluations = 0 - self._last_algebraic_diagnostics = None - self._last_algebraic_scope: tuple[str, ...] = () - self.max_stream_iterations = 0 - self.max_thermofluid_iterations = 0 - self.signal_propagation_count = 0 - self.pneumatic_volume_propagation_count = 0 - self._jacobian_sparsity = None - self._ode_tangent_provider: ThreePistonTangentProvider | None = None - self._activity_tracker: SolverActivityTracker | None = None - - def _request_causal_residual_audit(self) -> None: - """Make topology or mode boundaries verify the next causal closure.""" - - self.pressure_flow_solver.request_causal_audit() - for block_solver in ( - self._thermofluid_closure_plan.secondary_block_solvers - ): - block_solver.request_causal_audit() - - @staticmethod - def _overrides_stream_update(component: Component) -> bool: - component_type = type(component) - return ( - component_type.update_stream_outflows - is not Component.update_stream_outflows - or component_type.update_flow_temperature_references - is not Component.update_flow_temperature_references - ) - - def _physical_component_groups(self) -> tuple[tuple[str, ...], ...]: - """Return physical islands in component insertion order.""" - - physical_names = tuple( - component.name - for component in self.network.components.values() - if any( - definition.kind == "physical" - for definition in component.active_port_definitions - ) - ) - adjacency = {name: set() for name in physical_names} - for connection in self.network.connections: - if connection.kind != "physical": - continue - first, second = connection.endpoints - adjacency[first.component].add(second.component) - adjacency[second.component].add(first.component) - - groups: list[tuple[str, ...]] = [] - visited: set[str] = set() - for root in physical_names: - if root in visited: - continue - members = {root} - pending = [root] - visited.add(root) - while pending: - current = pending.pop() - for neighbor in adjacency[current]: - if neighbor in visited: - continue - visited.add(neighbor) - members.add(neighbor) - pending.append(neighbor) - groups.append(tuple(name for name in physical_names if name in members)) - return tuple(groups) - - def _network_for_component_group( - self, - component_names: tuple[str, ...], - all_physical_names: frozenset[str], - ) -> SimulationNetwork: - if frozenset(component_names) == all_physical_names: - return self.network - - selected = frozenset(component_names) - subnetwork = SimulationNetwork( - name=f"{self.network.name}:thermofluid:{len(component_names)}" - ) - for component in self.network.components.values(): - if component.name in selected: - subnetwork.add_component(component) - subnetwork.connections.extend( - connection - for connection in self.network.connections - if connection.kind == "physical" - and connection.endpoint_a.component in selected - and connection.endpoint_b.component in selected - ) - return subnetwork - - def _pressure_solver_for_component_group( - self, - component_names: tuple[str, ...], - all_physical_names: frozenset[str], - ) -> PressureFlowSolver: - subnetwork = self._network_for_component_group( - component_names, - all_physical_names, - ) - if subnetwork is self.network: - return self.pressure_flow_solver - return PressureFlowSolver( - subnetwork, - residual_tolerance=self.pressure_flow_solver.residual_tolerance, - max_evaluations=self.pressure_flow_solver.max_evaluations, - scope_kind="physicalIsland", - ) - - def _build_thermofluid_closure_plan(self) -> _ThermofluidClosurePlan: - physical_ports = tuple( - component.get_port(definition.name) - for component in self.network.components.values() - for definition in component.active_port_definitions - if definition.kind == "physical" - ) - physical_groups = self._physical_component_groups() - all_physical_names = frozenset( - name for group in physical_groups for name in group - ) - all_physical_order = tuple( - name for group in physical_groups for name in group - ) - - sensitive_names: set[str] = set() - has_unclassified_stream_component = False - has_invalid_dependency_declaration = False - for name in all_physical_names: - component = self.network.components[name] - # Only an exact-class declaration opts into pruning. A custom - # subclass cannot accidentally inherit a purity promise after - # changing its stream hook or constitutive equations. - declared = type(component).__dict__.get( - "PRESSURE_FLOW_DEPENDS_ON_STREAM" - ) - if declared is True: - sensitive_names.add(name) - elif declared is False: - continue - elif declared is None and self._overrides_stream_update(component): - # Preserve the exact legacy behavior for custom components that - # receive stream values but have not declared equation purity. - has_unclassified_stream_component = True - elif declared is not None: - has_invalid_dependency_declaration = True - - if has_invalid_dependency_declaration: - return _ThermofluidClosurePlan( - physical_ports=physical_ports, - global_component_group=all_physical_order, - secondary_pressure_solvers=(self.pressure_flow_solver,), - secondary_component_groups=(all_physical_order,), - uses_conservative_global_solver=True, - conservative_fallback_reason="invalidDependencyDeclaration", - ) - - if has_unclassified_stream_component: - return _ThermofluidClosurePlan( - physical_ports=physical_ports, - global_component_group=all_physical_order, - secondary_pressure_solvers=(self.pressure_flow_solver,), - secondary_component_groups=(all_physical_order,), - uses_conservative_global_solver=True, - conservative_fallback_reason="unclassifiedStreamComponent", - ) - - component_names = set(self.network.components) - compiled_equations = self.pressure_flow_solver.equation_templates - for equation in compiled_equations: - if equation.owner != "component": - continue - referenced_components = { - parts[0] - for variable in equation.variables - if len(parts := variable.rsplit(".", 2)) == 3 - and parts[0] in component_names - } - if referenced_components - {equation.owner_id}: - # Catalog equations are component-local and connectors carry - # cross-component constraints. A custom residual may violate - # that convention, so retain the unsplit global problem. - return _ThermofluidClosurePlan( - physical_ports=physical_ports, - global_component_group=all_physical_order, - secondary_pressure_solvers=(self.pressure_flow_solver,), - secondary_component_groups=(all_physical_order,), - uses_conservative_global_solver=True, - conservative_fallback_reason="crossComponentEquation", - ) - - for group in physical_groups: - selected = frozenset(group) - connection_ids = { - connection.id - for connection in self.network.connections - if connection.kind == "physical" - and connection.endpoint_a.component in selected - and connection.endpoint_b.component in selected - } - unknown_count = sum( - unknown.component in selected - for unknown in self.pressure_flow_solver.unknowns - ) - equation_count = sum( - ( - equation.owner == "component" - and equation.owner_id in selected - ) - or ( - equation.owner == "connection" - and equation.owner_id in connection_ids - ) - for equation in compiled_equations - ) - if unknown_count != equation_count: - # The full network can be square even when two disconnected - # rectangular islands happen to cancel each other's equation - # count. Preserve the original global least-squares problem in - # that unusual case rather than changing its solution space. - return _ThermofluidClosurePlan( - physical_ports=physical_ports, - global_component_group=all_physical_order, - secondary_pressure_solvers=(self.pressure_flow_solver,), - secondary_component_groups=(all_physical_order,), - uses_conservative_global_solver=True, - conservative_fallback_reason="nonSquarePhysicalIsland", - ) - - coupled_groups = tuple( - group for group in physical_groups if sensitive_names.intersection(group) - ) - secondary_pressure_solvers = tuple( - self._pressure_solver_for_component_group(group, all_physical_names) - for group in coupled_groups - ) - secondary_block_solvers = tuple( - StreamPressureBlockSolver( - pressure_solver, - tuple(name for name in group if name in sensitive_names), - ) - for pressure_solver, group in zip( - secondary_pressure_solvers, - coupled_groups, - ) - ) - return _ThermofluidClosurePlan( - physical_ports=physical_ports, - global_component_group=all_physical_order, - secondary_pressure_solvers=secondary_pressure_solvers, - secondary_component_groups=coupled_groups, - uses_conservative_global_solver=False, - conservative_fallback_reason=None, - secondary_block_solvers=secondary_block_solvers, - ) - - def initial_state_vector(self) -> list[float]: - return self.pneumatic_storage_reducer.synchronize_state_vector( - self.mechanical_state_reducer.initial_state_vector(), - validate=True, - ) - - def apply_state_vector(self, values: list[float]) -> None: - self.mechanical_state_reducer.apply_state_vector( - self.pneumatic_storage_reducer.synchronize_state_vector(values) - ) - - @staticmethod - def _entry_has_pneumatic_state(entry: object) -> bool: - """Return whether one reduced ODE entry owns pneumatic state. - - Mechanical constraint groups are synthetic state owners. Every other - entry is a dynamic component, so its active port metadata is the - topology-level way to classify it without depending on model names. - """ - - if isinstance(entry, MechanicalConstraintGroup): - return False - return any( - definition.kind == "physical" and definition.domain == "pneumatic" - for definition in entry.active_port_definitions - ) - - def _add_pneumatic_volume_state_dependencies( - self, - dependencies: list[set[int]], - entries: tuple[object, ...], - owner_by_component: dict[str, int], - ) -> None: - """Close the cross-domain dependency hidden by external volume ports. - - A pneumatic-volume source such as a piston writes swept volume from - mechanical coordinates into a connected storage component before the - pressure-flow closure. The ordinary physical-path walk intentionally - stops at a storage state. Consequently, a second storage connected to - that chamber can depend on the piston even though the path crosses the - chamber state, and that derivative was previously omitted from the BDF - sparsity pattern. - - Reuse the resolver's compiled output/connection plan to locate each - receiving storage. Mechanical states already found from that receiver, - the volume source's own ODE state (when it has one), and pneumatic states - whose local closure reaches the receiver form one conservative - cross-domain dependency set. Add it in both directions. If executable - custom/source metadata cannot bound those drivers, use a dense pattern. - """ - - resolver = self.pneumatic_volume_resolver - pneumatic_entries = tuple( - self._entry_has_pneumatic_state(entry) for entry in entries - ) - all_entry_indexes = set(range(len(entries))) - - def use_conservative_dense_pattern() -> None: - for entry_dependencies in dependencies: - entry_dependencies.update(all_entry_indexes) - - for component in resolver._output_components: - # ``pneumatic_volume_outputs`` is executable code rather than an - # equation-level dependency declaration. Catalog components with - # no directed signal input can be bounded by their own ODE state - # and the mechanical states already connected through topology. - # Custom/output components with an external signal driver keep the - # implicit integrator safe by disabling sparsity for this system. - if ( - not type(component).__module__.startswith( - "app.simulation.components." - ) - or any( - ( - definition.kind == "signal" - and definition.nominal_role == "input" - ) - or ( - definition.kind == "physical" - and definition.domain - not in {"pneumatic", "mechanical"} - ) - for definition in component.active_port_definitions - ) - ): - use_conservative_dense_pattern() - return - - source_index = owner_by_component.get(component.name) - receiver_indexes: set[int] = set() - for definition in component.active_port_definitions: - if ( - definition.kind != "physical" - or definition.domain != "pneumatic" - ): - continue - binding = resolver._connected_endpoint.get( - Endpoint(component.name, definition.name) - ) - if binding is None: - continue - receiver_index = owner_by_component.get( - binding.connected_endpoint.component - ) - if receiver_index is not None and pneumatic_entries[receiver_index]: - receiver_indexes.add(receiver_index) - - for receiver_index in receiver_indexes: - driver_indexes = { - entry_index - for entry_index in dependencies[receiver_index] - if isinstance(entries[entry_index], MechanicalConstraintGroup) - } - if source_index is not None: - driver_indexes.add(source_index) - if not driver_indexes: - use_conservative_dense_pattern() - return - coupled_pneumatic_indexes = { - entry_index - for entry_index, is_pneumatic in enumerate(pneumatic_entries) - if is_pneumatic - and ( - entry_index == receiver_index - or receiver_index in dependencies[entry_index] - ) - } - for pneumatic_index in coupled_pneumatic_indexes: - dependencies[pneumatic_index].update(driver_indexes) - for driver_index in driver_indexes: - dependencies[driver_index].update( - coupled_pneumatic_indexes - ) - - def _build_jacobian_sparsity(self): - """Build a conservative state dependency graph for implicit solvers. - - Two state entries are coupled when a physical path connects them without - crossing a third storage state. This over-approximates the local - pressure-flow/mechanical closure while preserving branch sparsity. - """ - from scipy.sparse import lil_matrix - - entries = self.mechanical_state_reducer.state_entries - entry_components: list[set[str]] = [] - entry_sizes: list[int] = [] - for entry in entries: - if isinstance(entry, MechanicalConstraintGroup): - entry_components.append( - {component.name for component in entry.components} - ) - entry_sizes.append(2) - else: - entry_components.append({entry.name}) - entry_sizes.append(entry.state_size) - - owner_by_component = { - component_name: entry_index - for entry_index, component_names in enumerate(entry_components) - for component_name in component_names - } - adjacency = {name: set() for name in self.network.components} - for connection in self.network.connections: - if connection.kind != "physical": - continue - first, second = connection.endpoints - adjacency[first.component].add(second.component) - adjacency[second.component].add(first.component) - - dependencies: list[set[int]] = [] - for entry_index, component_names in enumerate(entry_components): - visited = set(component_names) - pending = list(component_names) - found = {entry_index} - while pending: - current = pending.pop() - for neighbour in adjacency[current] - visited: - visited.add(neighbour) - neighbour_entry = owner_by_component.get(neighbour) - if ( - neighbour_entry is not None - and neighbour_entry != entry_index - ): - found.add(neighbour_entry) - else: - pending.append(neighbour) - dependencies.append(found) - - self._add_pneumatic_volume_state_dependencies( - dependencies, - entries, - owner_by_component, - ) - - offsets = [0] - for state_size in entry_sizes: - offsets.append(offsets[-1] + state_size) - sparsity = lil_matrix( - (offsets[-1], offsets[-1]), - dtype=bool, - ) - for row_entry, column_entries in enumerate(dependencies): - for column_entry in column_entries: - sparsity[ - offsets[row_entry] : offsets[row_entry + 1], - offsets[column_entry] : offsets[column_entry + 1], - ] = True - return sparsity.tocsr() - - def jacobian_sparsity(self): - if self._jacobian_sparsity is None: - self._jacobian_sparsity = self._build_jacobian_sparsity() - return self._jacobian_sparsity - - def jacobian_sparsity_diagnostics(self) -> dict[str, float | int]: - from scipy.optimize._numdiff import group_columns - - sparsity = self.jacobian_sparsity() - group_count = int(group_columns(sparsity).max(initial=-1)) + 1 - state_count = int(sparsity.shape[0]) - return { - "nonzeroCount": int(sparsity.nnz), - "density": ( - float(sparsity.nnz) / float(state_count * state_count) - if state_count - else 0.0 - ), - "colorGroupCount": group_count, - } - - def _exact_ode_jacobian_rows(self) -> dict[int, dict[int, float]]: - """Return mode-independent kinematic rows safe to evaluate exactly.""" - - rows: dict[int, dict[int, float]] = {} - cursor = 0 - for entry in self.mechanical_state_reducer.state_entries: - if isinstance(entry, MechanicalConstraintGroup): - # A discrete endstop can replace x' = v with x' = 0 for the - # active constrained mode. Keep those rows numerical; free - # mechanical groups always have d(x')/d(v) = 1. - if not entry.discrete_endstop_components: - rows[cursor + 1] = {cursor: 1.0} - cursor += 2 - else: - cursor += entry.state_size - return rows - - @profile_phase( - "simulation.closure", - minimum_mode="audit", - reset_property_shadow=True, - ) - def _close_current_state( - self, - time: float, - *, - record_rhs_outcome: bool = False, - ) -> dict[str, dict[str, float]]: - if self._activity_tracker is not None: - self._activity_tracker.record_thermofluid_closure(time) - transaction = self._thermofluid_transaction_plan.capture() - last_algebraic_diagnostics = self._last_algebraic_diagnostics - last_algebraic_scope = self._last_algebraic_scope - try: - connected_h, success = self._close_current_state_unchecked(time) - except ThermofluidClosureError as exc: - transaction.restore() - self._last_algebraic_diagnostics = last_algebraic_diagnostics - self._last_algebraic_scope = last_algebraic_scope - self._request_causal_residual_audit() - if record_rhs_outcome: - failure = self._thermofluid_closure_diagnostics.record_failure( - exc.diagnostics - ) - raise ThermofluidClosureError(failure) from None - raise - except BaseException: - transaction.restore() - self._last_algebraic_diagnostics = last_algebraic_diagnostics - self._last_algebraic_scope = last_algebraic_scope - self._request_causal_residual_audit() - raise - if record_rhs_outcome: - self._thermofluid_closure_diagnostics.record_success(success) - return connected_h - - def _close_current_state_unchecked( - self, - time: float, - ) -> tuple[ - dict[str, dict[str, float]], - ThermofluidClosureSuccess, - ]: - signal = self.signal_resolver.solve(time) - self.signal_propagation_count += signal.propagated - self.pressure_flow_solver.propagate_equal_efforts(("x", "v")) - pneumatic_volume = self.pneumatic_volume_resolver.solve() - self.pneumatic_volume_propagation_count += pneumatic_volume.propagated - self._refresh_dynamic_components() - initial_algebraic = self.pressure_flow_solver.solve( - effort_variables=("p",), - ) - algebraic_diagnostics = [initial_algebraic] - pressure_flow_solve_count = 1 - self.thermofluid_pressure_pass_count += 1 - - # Some constitutive flow laws recover their upstream temperature from - # connected stream enthalpy, while junction stream mixing itself depends - # on the resulting mass flows. A single stream -> pressure-flow refresh - # leaves that two-way coupling to the next RHS call, making the ODE RHS - # depend on evaluation history and corrupting finite-difference - # Jacobians. Close both layers to one fixed point inside this call. - # The compiled closure plan keeps custom stream-aware components on the - # legacy global path. For catalog models, only stream-sensitive physical - # islands are revisited; independent islands keep the first solve. - closure_plan = self._thermofluid_closure_plan - self._last_algebraic_diagnostics = initial_algebraic - self._last_algebraic_scope = closure_plan.global_component_group - secondary_pressure_solvers = closure_plan.secondary_pressure_solvers - secondary_block_solvers = closure_plan.secondary_block_solvers - connected_h: dict[str, dict[str, float]] = {} - stream_diagnostics = [] - coupling_deltas = [] - max_coupling_iterations = 25 - flow_relative_tolerance = 1.0e-12 - for coupling_iteration in range(1, max_coupling_iterations + 1): - previous_flows = self._thermofluid_transaction_plan.flow_values() - stream, connected_h = self.stream_resolver.solve( - dynamic_ports_are_current=True, - ) - stream_diagnostics.append(stream) - for component in self.dynamic_components: - component.update_stream_outflows(connected_h[component.name]) - self.stream_resolver.refresh_flow_temperature_references() - if secondary_pressure_solvers: - self.thermofluid_pressure_pass_count += 1 - block_scale_context = ( - self.pressure_flow_solver.scale_context() - if any( - solver is not self.pressure_flow_solver - for solver in secondary_pressure_solvers - ) - else None - ) - if ( - secondary_block_solvers - and not closure_plan.uses_conservative_global_solver - ): - # Stream propagation only invalidates equations that explicitly - # consume the new enthalpy/temperature references. Re-solve the - # exact equation/unknown blocks containing those equations; the - # first global pass above remains the causalization boundary for - # mechanics, contact, and all stream-independent pneumatic blocks. - for block_solver in secondary_block_solvers: - block_result = block_solver.solve( - scale_context=block_scale_context, - ) - # One public secondary closure is one logical solve. The - # block solver folds every local attempt and a possible - # accepted global fallback into this single diagnostic, so - # evaluations and blockFallbackUsed are counted exactly - # once here rather than once per internal equation block. - (algebraic,) = block_result.diagnostics - (scope,) = block_result.scopes - algebraic_diagnostics.append(algebraic) - self._last_algebraic_diagnostics = algebraic - self._last_algebraic_scope = scope - pressure_flow_solve_count += 1 - else: - for pressure_solver, component_group in zip( - secondary_pressure_solvers, - closure_plan.secondary_component_groups, - ): - algebraic = pressure_solver.solve( - effort_variables=(), - scale_context=( - block_scale_context - if pressure_solver is not self.pressure_flow_solver - else None - ), - ) - algebraic_diagnostics.append(algebraic) - self._last_algebraic_diagnostics = algebraic - self._last_algebraic_scope = component_group - pressure_flow_solve_count += 1 - coupling_delta = self._thermofluid_transaction_plan.measure_flow_delta( - previous_flows, - iteration=coupling_iteration, - relative_tolerance=flow_relative_tolerance, - ) - coupling_deltas.append(coupling_delta) - if ( - not secondary_pressure_solvers - or coupling_delta.max_delta <= coupling_delta.tolerance - ): - break - else: - raise ThermofluidClosureError( - ThermofluidClosureFailure.from_iterations( - time, - coupling_deltas, - ) - ) - self.max_thermofluid_iterations = max( - self.max_thermofluid_iterations, - coupling_iteration, - ) - self.mechanical_state_reducer.update_constraint_accelerations() - self.algebraic_solve_count += pressure_flow_solve_count - seeded_count = sum( - item.jacobian_mode == "seeded" for item in algebraic_diagnostics - ) - self.algebraic_seeded_solve_count += seeded_count - self.algebraic_nonlinear_solve_count += ( - len(algebraic_diagnostics) - seeded_count - ) - self.algebraic_optimizer_evaluation_count += sum( - item.evaluations for item in algebraic_diagnostics - ) - self.algebraic_residual_evaluation_count += sum( - item.residual_evaluations for item in algebraic_diagnostics - ) - self.algebraic_block_fallback_count += sum( - item.block_fallback_used for item in algebraic_diagnostics - ) - self.algebraic_dense_fallback_count += sum( - item.dense_fallback_used for item in algebraic_diagnostics - ) - self.max_algebraic_residual = max( - self.max_algebraic_residual, - *(item.max_scaled_residual for item in algebraic_diagnostics), - ) - self.max_algebraic_evaluations = max( - self.max_algebraic_evaluations, - *(item.evaluations for item in algebraic_diagnostics), - ) - self.max_algebraic_residual_evaluations = max( - self.max_algebraic_residual_evaluations, - *(item.residual_evaluations for item in algebraic_diagnostics), - ) - self.max_stream_iterations = max( - self.max_stream_iterations, - *(item.iterations for item in stream_diagnostics), - ) - return connected_h, ThermofluidClosureSuccess.from_iteration( - time, - coupling_deltas[-1], - ) - - @profile_phase("simulation.refresh", minimum_mode="audit") - def _refresh_dynamic_components(self) -> None: - for component in self.dynamic_components: - component.refresh_thermodynamic_ports() - - @profile_phase("simulation.derivatives", minimum_mode="audit") - def _state_derivatives( - self, - connected_h: dict[str, dict[str, float]], - ) -> list[float]: - if self._activity_tracker is not None: - self._activity_tracker.record_phase("state_derivatives") - return self.pneumatic_storage_reducer.coupled_derivatives( - self.mechanical_state_reducer.state_derivatives(connected_h) - ) - - def consistent_initial_state_vector(self, time: float = 0.0) -> list[float]: - state = self.initial_state_vector() - self.apply_state_vector(state) - self._close_current_state(time) - return state - - @profile_phase("simulation.rhs", minimum_mode="audit") - def rhs(self, _time: float, state_vector: list[float]) -> list[float]: - self.apply_state_vector(state_vector) - connected_h = self._close_current_state( - _time, - record_rhs_outcome=True, - ) - derivatives = self._state_derivatives(connected_h) - provider = self._ode_tangent_provider - if provider is not None: - provider.record_primal(_time, state_vector, connected_h) - return derivatives - - def _append_current_state(self, series: dict[str, list[float]]) -> None: - for component in self.network.components.values(): - for relative_key, value in component.result_values().items(): - series.setdefault( - f"{component.name}.{relative_key}", [] - ).append(value) - - @with_property_cache - def simulate( - self, - config: SolveIVPConfig, - *, - sample_step: float, - progress_callback: SimulationProgressCallback | None = None, - cancel_check: SimulationCancellationCheck | None = None, - activity_tracker: SolverActivityTracker | None = None, - ) -> GenericSimulationResult: - previous_activity_tracker = self._activity_tracker - self._activity_tracker = activity_tracker - try: - return self._simulate( - config, - sample_step=sample_step, - progress_callback=progress_callback, - cancel_check=cancel_check, - activity_tracker=activity_tracker, - ) - finally: - self._activity_tracker = previous_activity_tracker - - def _simulate( - self, - config: SolveIVPConfig, - *, - sample_step: float, - progress_callback: SimulationProgressCallback | None = None, - cancel_check: SimulationCancellationCheck | None = None, - activity_tracker: SolverActivityTracker | None = None, - ) -> GenericSimulationResult: - if activity_tracker is not None: - activity_tracker.record_phase("initializing", config.t_start) - last_reported_progress = -1.0 - last_reported_phase = "" - - def report_progress( - progress: float, - phase: str, - *, - force: bool = False, - ) -> None: - nonlocal last_reported_phase, last_reported_progress - if progress_callback is None: - return - bounded_progress = min(1.0, max(0.0, progress)) - if ( - force - or phase != last_reported_phase - or bounded_progress - last_reported_progress >= 0.0025 - ): - last_reported_phase = phase - last_reported_progress = max( - last_reported_progress, - bounded_progress, - ) - progress_callback(last_reported_progress, phase) - - report_progress(0.0, "initializing", force=True) - with performance_span("simulation.sample_initialization"): - integration_config = config - mechanical_tolerance_plan = None - if isinstance(config.atol, (int, float)): - mechanical_tolerance_plan = ( - self.mechanical_state_reducer.absolute_tolerance_plan( - float(config.atol), - mode=( - None - if config.method == "BDF" - else "legacy" - ), - ) - ) - integration_config = replace( - config, - atol=list(mechanical_tolerance_plan.values), - ) - t_eval = simulation_sample_times(config, sample_step) - signal_event_times = self.signal_resolver.event_times( - config.t_start, - config.t_stop, - ) - initial_state = self.consistent_initial_state_vector(config.t_start) - jac_sparsity = ( - self.jacobian_sparsity() - if integration_config.method in {"BDF", "Radau"} - else None - ) - report_progress(0.0, "integrating", force=True) - duration = config.t_stop - config.t_start - furthest_solver_time = config.t_start - next_signal_audit_index = 0 - - def report_solver_time(time: float) -> None: - nonlocal furthest_solver_time - furthest_solver_time = max(furthest_solver_time, float(time)) - time_fraction = ( - (furthest_solver_time - config.t_start) / duration - if duration > 0.0 - else 1.0 - ) - report_progress(time_fraction, "integrating") - - def monitored_rhs(time: float, state_vector: list[float]) -> list[float]: - nonlocal next_signal_audit_index - while ( - next_signal_audit_index < len(signal_event_times) - and float(time) >= signal_event_times[next_signal_audit_index] - ): - self._request_causal_residual_audit() - next_signal_audit_index += 1 - if cancel_check is None: - report_solver_time(time) - return self.rhs(time, state_vector) - - jacobian = None - jacobian_fallback_reason: str | None = None - tangent_compilation: ThreePistonTangentCompilation | None = None - selected_tangent_provider: ThreePistonTangentProvider | None = None - self._ode_tangent_provider = None - requested_jacobian_mode = ( - _requested_ode_jacobian_mode() - if jac_sparsity is not None - else "scipy" - ) - if ( - jac_sparsity is not None - and requested_jacobian_mode - in {"optimized", "hybrid", "semi-analytic"} - ): - state_count = int(jac_sparsity.shape[0]) - if ( - requested_jacobian_mode == "hybrid" - and not self.pressure_flow_solver.causal_fast_path_enabled - ): - jacobian_fallback_reason = "causalAlgebraicExecutionUnavailable" - elif int(jac_sparsity.nnz) >= state_count * state_count: - jacobian_fallback_reason = "denseStateDependencyPattern" - else: - exact_columns = None - if requested_jacobian_mode == "semi-analytic": - tangent_compilation = ( - compile_supported_piston_tangent_provider(self) - ) - if tangent_compilation.eligible: - provider = tangent_compilation.provider - if provider is None: - raise RuntimeError( - "An eligible tangent compilation has no provider." - ) - selected_tangent_provider = provider - exact_columns = ( - tangent_compilation.columns, - provider, - ) - else: - jacobian_fallback_reason = ( - f"semiAnalytic:{tangent_compilation.reason}" - ) - - if ( - requested_jacobian_mode != "semi-analytic" - or tangent_compilation is not None - and tangent_compilation.eligible - ): - def evaluate_jacobian_rhs(time, state): - if cancel_check is not None and cancel_check(): - raise IntegrationCancelled - if activity_tracker is not None: - activity_tracker.record_rhs(float(time)) - return monitored_rhs( - time, - [float(value) for value in state], - ) - - try: - jacobian = SparseSecantJacobian( - evaluate_jacobian_rhs, - jac_sparsity, - integration_config.atol, - exact_rows=self._exact_ode_jacobian_rows(), - exact_columns=exact_columns, - max_consecutive_reuses=( - 1 - if requested_jacobian_mode == "hybrid" - else 0 - ), - ) - self._ode_tangent_provider = ( - selected_tangent_provider - ) - except SparseJacobianCompatibilityError as exc: - jacobian_fallback_reason = ( - f"scipyCompatibility:{type(exc).__name__}" - ) - - def handle_state_transition(*args): - transition = self.mechanical_state_reducer.state_transition(*args) - if transition is not None: - self._request_causal_residual_audit() - return transition - - try: - solution = integrate_ode( - rhs=monitored_rhs, - initial_state=initial_state, - config=integration_config, - t_eval=t_eval, - cancel_check=cancel_check, - accepted_step_callback=( - report_solver_time if cancel_check is not None else None - ), - breakpoints=signal_event_times, - state_transition_handler=( - handle_state_transition - if self.mechanical_state_reducer.has_state_events - else None - ), - jac_sparsity=jac_sparsity, - jac=jacobian, - recoverable_trial_retries=True, - activity_tracker=activity_tracker, - ) - finally: - self._ode_tangent_provider = None - if isinstance(solution, ODESolution): - run_status: SimulationRunStatus = solution.status - integration_error = solution.error - else: - run_status = "completed" if bool(solution.success) else "failed" - integration_error = None - result_message = str(solution.message) - postprocess_progress = ( - 1.0 - if run_status == "completed" - else max(0.0, last_reported_progress) - ) - report_progress(postprocess_progress, "postprocessing", force=True) - if activity_tracker is not None: - activity_tracker.record_phase( - "postprocessing", - furthest_solver_time, - ) - times = [float(value) for value in solution.t] - if isinstance(solution, ODESolution): - solver_segment_diagnostics = [ - segment.as_dict() for segment in solution.solver_segments - ] - else: - solver_segment_diagnostics = [ - { - "startTime": float(config.t_start), - "requestedStopTime": float(config.t_stop), - "simulatedUntil": times[-1] if times else float(config.t_start), - "nfev": int(getattr(solution, "nfev", 0)), - "njev": int(getattr(solution, "njev", 0)), - "nlu": int(getattr(solution, "nlu", 0)), - "acceptedStepCount": 0, - "solverStartCount": 1, - "stateTransitionCount": 0, - "recoverableRetryCount": 0, - } - ] - if jacobian is not None: - direct_jacobian = jacobian.diagnostics() - solver_segment_diagnostics[0].update( - { - "jacobianEvaluationCount": int( - direct_jacobian["jacobianEvaluationCount"] - ), - "jacobianFullBuildCount": int( - direct_jacobian["fullBuildCount"] - ), - "jacobianSecantReuseCount": int( - direct_jacobian["secantReuseCount"] - ), - "jacobianAuditFailureCount": int( - direct_jacobian["auditFailureCount"] - ), - "finiteDifferenceRhsEvaluationCount": int( - direct_jacobian[ - "finiteDifferenceRhsEvaluationCount" - ] - ), - "jacobianBaseRhsEvaluationCount": int( - direct_jacobian["baseRhsEvaluationCount"] - ), - "jacobianJvAuditRhsEvaluationCount": int( - direct_jacobian["jvAuditEvaluationCount"] - ), - "exactColumnBuildCount": int( - direct_jacobian["exactColumnBuildCount"] - ), - "exactColumnFallbackCount": int( - direct_jacobian["exactColumnFallbackCount"] - ), - "jacobianAssemblySeconds": float( - direct_jacobian["assemblySeconds"] - ), - } - ) - solver_total_keys = ( - "nfev", - "njev", - "nlu", - "acceptedStepCount", - "solverStartCount", - "stateTransitionCount", - "recoverableRetryCount", - ) - solver_totals = { - key: sum(int(segment[key]) for segment in solver_segment_diagnostics) - for key in solver_total_keys - } - jacobian_work_keys = ( - "jacobianEvaluationCount", - "jacobianFullBuildCount", - "jacobianSecantReuseCount", - "jacobianAuditFailureCount", - "finiteDifferenceRhsEvaluationCount", - "jacobianBaseRhsEvaluationCount", - "jacobianJvAuditRhsEvaluationCount", - "exactColumnBuildCount", - "exactColumnFallbackCount", - "jacobianAssemblySeconds", - ) - for key in jacobian_work_keys: - if any(key in segment for segment in solver_segment_diagnostics): - solver_totals[key] = sum( - segment.get(key, 0) - for segment in solver_segment_diagnostics - ) - jacobian_diagnostics = ( - self.jacobian_sparsity_diagnostics() - if integration_config.method in {"BDF", "Radau"} - else None - ) - runtime_jacobian_diagnostics: dict[str, object] | None = None - if jacobian_diagnostics is not None: - color_group_count = int(jacobian_diagnostics["colorGroupCount"]) - if jacobian is None: - for segment in solver_segment_diagnostics: - segment["finiteDifferenceRhsEstimate"] = ( - int(segment["njev"]) * color_group_count - ) - runtime_jacobian_diagnostics = { - "mode": "scipySparseFiniteDifference", - "fallbackReason": jacobian_fallback_reason, - "jacobianEvaluationCount": int(solver_totals["njev"]), - "fullBuildCount": int(solver_totals["njev"]), - "finiteDifferenceRhsEstimateIsExact": False, - } - else: - for segment in solver_segment_diagnostics: - segment["finiteDifferenceRhsEstimate"] = int( - segment.get("finiteDifferenceRhsEvaluationCount", 0) - ) + int( - segment.get("jacobianJvAuditRhsEvaluationCount", 0) - ) - runtime_jacobian_diagnostics = dict(jacobian.diagnostics()) - runtime_jacobian_diagnostics.update( - { - "fallbackReason": jacobian_fallback_reason, - "finiteDifferenceRhsEstimateIsExact": True, - } - ) - if tangent_compilation is not None: - runtime_jacobian_diagnostics["tangentCompilation"] = ( - tangent_compilation.diagnostics() - ) - if tangent_compilation.eligible and jacobian is not None: - runtime_jacobian_diagnostics["mode"] = ( - "semiAnalyticExactColumns" - ) - exact_builds = int( - runtime_jacobian_diagnostics[ - "exactColumnBuildCount" - ] - ) - exact_fallbacks = int( - runtime_jacobian_diagnostics[ - "exactColumnFallbackCount" - ] - ) - if exact_builds == 0 and exact_fallbacks == 0: - effective_mode = "notEvaluated" - elif exact_builds == 0: - effective_mode = "numericalFallbackOnly" - elif exact_fallbacks: - effective_mode = "mixedExactAndNumericalFallback" - else: - effective_mode = "exactColumns" - runtime_jacobian_diagnostics["effectiveMode"] = ( - effective_mode - ) - if exact_fallbacks: - runtime_jacobian_diagnostics[ - "runtimeFallbackReason" - ] = runtime_jacobian_diagnostics[ - "lastExactColumnFallbackReason" - ] - solver_totals["finiteDifferenceRhsEstimate"] = sum( - int(segment["finiteDifferenceRhsEstimate"]) - for segment in solver_segment_diagnostics - ) - if jacobian is None: - solver_totals["jacobianRhsEvaluationCountEstimate"] = ( - int(solver_totals["finiteDifferenceRhsEstimate"]) - + int(solver_totals["njev"]) - ) - else: - solver_totals["jacobianRhsEvaluationCount"] = ( - int( - solver_totals.get( - "finiteDifferenceRhsEvaluationCount", - 0, - ) - ) - + int( - solver_totals.get( - "jacobianBaseRhsEvaluationCount", - 0, - ) - ) - + int( - solver_totals.get( - "jacobianJvAuditRhsEvaluationCount", - 0, - ) - ) - ) - - with performance_span("simulation.postprocessing"): - series: dict[str, list[float]] = {"time": []} - postprocessing_error: Exception | None = None - self.mechanical_state_reducer.reset_constraint_modes() - for time_index in range(len(times)): - if ( - run_status == "completed" - and cancel_check is not None - and cancel_check() - ): - run_status = "cancelled" - result_message = ( - "Simulation was stopped while preparing partial results." - ) - break - state = [ - float(solution.y[state_index][time_index]) - for state_index in range(len(solution.y)) - ] - try: - self.apply_state_vector(state) - self._close_current_state(times[time_index]) - self._append_current_state(series) - series["time"].append(times[time_index]) - if activity_tracker is not None: - activity_tracker.record_phase( - "postprocessing", - times[time_index], - ) - except Exception as exc: - run_status = "failed" - result_message = str(exc) - postprocessing_error = exc - break - if len(series["time"]) < 2: - if postprocessing_error is not None: - raise postprocessing_error - if integration_error is not None: - raise integration_error - - with performance_span("simulation.result_assembly"): - final = { - key: values[-1] - for key, values in series.items() - if key != "time" and values - } - diagnostics = { - "integration": { - "method": integration_config.method, - "mechanicalAbsoluteTolerance": ( - mechanical_tolerance_plan.as_dict() - if mechanical_tolerance_plan is not None - else { - "mode": "callerVector", - "stateCount": len(initial_state), - } - ), - "jacobianSparsity": jacobian_diagnostics, - "jacobian": runtime_jacobian_diagnostics, - "segmentCount": len(solver_segment_diagnostics), - "segments": solver_segment_diagnostics, - "totals": solver_totals, - }, - "pressureFlow": { - "solveCount": self.algebraic_solve_count, - "seededSolveCount": self.algebraic_seeded_solve_count, - "nonlinearSolveCount": self.algebraic_nonlinear_solve_count, - "fastPathHitRate": ( - self.algebraic_seeded_solve_count - / self.algebraic_solve_count - if self.algebraic_solve_count - else 0.0 - ), - "optimizerEvaluationCount": ( - self.algebraic_optimizer_evaluation_count - ), - "residualEvaluationCount": ( - self.algebraic_residual_evaluation_count - ), - "blockFallbackCount": self.algebraic_block_fallback_count, - "denseFallbackCount": self.algebraic_dense_fallback_count, - "closurePassCount": self.thermofluid_pressure_pass_count, - "secondaryPhysicalIslandCount": len( - self._thermofluid_closure_plan.secondary_pressure_solvers - ), - "secondaryBlockCount": sum( - len(solver.blocks) - for solver in self._thermofluid_closure_plan.secondary_block_solvers - if solver.available - ), - "secondaryUnknownCount": sum( - len(block.unknowns) - for solver in self._thermofluid_closure_plan.secondary_block_solvers - if solver.available - for block in solver.blocks - ), - "equationBlockFallbackReasons": [ - solver.fallback_reason - for solver in self._thermofluid_closure_plan.secondary_block_solvers - if solver.fallback_reason is not None - ], - "usesConservativeGlobalCoupling": ( - self._thermofluid_closure_plan.uses_conservative_global_solver - ), - "couplingPlanFallbackReason": ( - self._thermofluid_closure_plan.conservative_fallback_reason - ), - "maxScaledResidual": self.max_algebraic_residual, - "maxEvaluationsPerSolve": self.max_algebraic_evaluations, - "maxResidualEvaluationsPerSolve": ( - self.max_algebraic_residual_evaluations - ), - "lastScope": list(self._last_algebraic_scope), - "last": ( - self._last_algebraic_diagnostics.as_dict() - if self._last_algebraic_diagnostics is not None - else None - ), - "causalExecution": ( - self.pressure_flow_solver.causal_execution_diagnostics() - ), - "secondaryCausalExecution": [ - solver.causal_execution_diagnostics() - for solver in self._thermofluid_closure_plan.secondary_block_solvers - ], - }, - "stream": { - "maxIterationsPerSolve": self.max_stream_iterations, - "maxThermofluidIterations": self.max_thermofluid_iterations, - "thermofluidClosure": { - **self._thermofluid_closure_diagnostics.as_dict(), - "transaction": ( - self._thermofluid_transaction_plan.diagnostics() - ), - }, - "last": ( - self.stream_resolver.last_diagnostics.as_dict() - if self.stream_resolver.last_diagnostics is not None - else None - ), - }, - "signal": { - "propagations": self.signal_propagation_count, - "eventTimes": list(signal_event_times), - "last": ( - self.signal_resolver.last_diagnostics.as_dict() - if self.signal_resolver.last_diagnostics is not None - else None - ), - }, - "pneumaticVolume": { - "propagations": self.pneumatic_volume_propagation_count, - "last": ( - self.pneumatic_volume_resolver.last_diagnostics.as_dict() - if self.pneumatic_volume_resolver.last_diagnostics is not None - else None - ), - }, - "stateCount": len(initial_state), - "sampleCount": len(series["time"]), - } - variables = tuple( - variable - for variable in self.network.result_variable_metadata() - if variable.key in series - ) - final_simulated_time = ( - float(series["time"][-1]) - if series["time"] - else float(config.t_start) - ) - if activity_tracker is not None: - activity_tracker.record_phase( - "complete" if run_status == "completed" else run_status, - final_simulated_time, - ) - diagnostics["activity"] = ( - activity_tracker.snapshot().as_dict() - ) - report_progress( - 1.0 if run_status == "completed" else max(0.0, last_reported_progress), - "complete" if run_status == "completed" else run_status, - force=True, - ) - return GenericSimulationResult( - success=run_status == "completed" and bool(solution.success), - status=run_status, - message=result_message, - simulated_until=( - final_simulated_time - ), - requested_stop_time=float(config.t_stop), - variables=variables, - series=series, - final=final, - diagnostics=diagnostics, - ) diff --git a/app/simulation/systems/network.py b/app/simulation/systems/network.py index abbaf32..5860b77 100644 --- a/app/simulation/systems/network.py +++ b/app/simulation/systems/network.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from app.simulation.core.base import Component, DynamicComponent -from app.simulation.core.equations import EquationResidual +from app.simulation.core.equations import EquationDefinition from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.core.ports import PortState @@ -182,62 +182,25 @@ class SimulationNetwork: ) return component.get_port(endpoint.port) - def connection_equation_residuals(self) -> tuple[EquationResidual, ...]: - """Evaluate connector equations that have a direct scalar residual. - - Stream variables are resolved by the stream-mixing layer and therefore do - not incorrectly appear here as an equality between outflow properties. - """ - - residuals: list[EquationResidual] = [] + def connection_equation_definitions(self) -> tuple[EquationDefinition, ...]: + equations=[] for connection in self.connections: - if connection.kind != "physical": - continue - - first_port = self._port_for(connection.endpoint_a) - second_port = self._port_for(connection.endpoint_b) - definition = first_port.definition - if definition is None: - raise ValueError( - f"Connected port {connection.endpoint_a} has no interface definition." - ) - + if connection.kind!='physical':continue + definition=self._port_for(connection.endpoint_a).definition for variable in definition.variables: - if variable.connection_rule == "equal": - value = float(getattr(first_port, variable.name)) - float( - getattr(second_port, variable.name) - ) - elif variable.connection_rule == "sumToZero": - value = float(getattr(first_port, variable.name)) + float( - getattr(second_port, variable.name) - ) - else: - continue - residuals.append( - EquationResidual( - id=f"{connection.id}:{variable.name}", - owner="connection", - owner_id=connection.id, - relation=variable.connection_rule, - variables=( - f"{connection.endpoint_a}.{variable.name}", - f"{connection.endpoint_b}.{variable.name}", - ), - role=variable.role, - value=value, - ) - ) - return tuple(residuals) + if variable.connection_rule not in ('equal','sumToZero'):continue + equations.append(EquationDefinition(id=f'{connection.id}:{variable.name}',owner='connection',owner_id=connection.id,relation=variable.connection_rule,variables=(f'{connection.endpoint_a}.{variable.name}',f'{connection.endpoint_b}.{variable.name}'),role=variable.role)) + return tuple(equations) - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: + def equation_definitions(self) -> tuple[EquationDefinition, ...]: """Evaluate the complete algebraic pressure-flow equation subsystem.""" component_residuals = tuple( residual for component in self.components.values() - for residual in component.pressure_flow_equation_residuals() + for residual in component.equation_definitions() ) - return component_residuals + self.connection_equation_residuals() + return component_residuals + self.connection_equation_definitions() def pressure_flow_unknowns(self) -> tuple[str, ...]: return tuple( @@ -251,7 +214,7 @@ class SimulationNetwork: def pressure_flow_structure_dict(self) -> dict[str, object]: unknowns = self.pressure_flow_unknowns() - equations = self.pressure_flow_equation_residuals() + equations = self.equation_definitions() return { "unknownCount": len(unknowns), "equationCount": len(equations), @@ -269,20 +232,7 @@ class SimulationNetwork: if isinstance(component, DynamicComponent) ] - def initial_state_vector(self) -> list[float]: - values: list[float] = [] - for component in self.dynamic_components(): - values.extend(component.get_state_vector()) - return values - def apply_state_vector(self, values: list[float]) -> None: - cursor = 0 - for component in self.dynamic_components(): - next_cursor = cursor + component.state_size - component.set_state_vector(values[cursor:next_cursor]) - cursor = next_cursor - if cursor != len(values): - raise ValueError("State vector length does not match dynamic components.") def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]: return tuple( diff --git a/app/simulation/warmup.py b/app/simulation/warmup.py index a899933..5b7cf0d 100644 --- a/app/simulation/warmup.py +++ b/app/simulation/warmup.py @@ -39,68 +39,19 @@ def simulation_warmup_enabled() -> bool: ) -def _run_numerical_warmup() -> None: - """Exercise only in-memory SciPy paths used by real simulations.""" - import numpy as np - from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau, solve_ivp - from scipy.optimize import brentq, least_squares - from scipy.optimize._numdiff import group_columns - from scipy.sparse import csc_matrix, csr_matrix - # Importing these classes is intentional even though the micro solve below - # uses BDF: the stepwise solver selects them dynamically at runtime. - solver_types = (BDF, DOP853, LSODA, RK23, RK45, Radau) - if len(solver_types) != 6: - raise RuntimeError("SciPy solver warm-up did not load every supported method.") +def _run_native_warmup() -> None: + """Check the native toolchain and XML schema without running SciPy solvers. - sparsity = csc_matrix(np.array([[1.0]], dtype=float)) - groups = group_columns(sparsity) - if groups.shape != (1,): - raise RuntimeError("SciPy Jacobian grouping warm-up returned an invalid shape.") - - integration = solve_ivp( - lambda _time, state: -state, - (0.0, 1.0e-4), - np.array([1.0], dtype=float), - method="BDF", - t_eval=np.array([0.0, 1.0e-4], dtype=float), - jac_sparsity=sparsity, - rtol=1.0e-6, - atol=1.0e-9, - ) - if not integration.success or not np.isfinite(integration.y).all(): - raise RuntimeError("SciPy integration warm-up did not complete successfully.") - - algebraic_sparsity = csr_matrix(np.eye(2, dtype=bool)) - algebraic = least_squares( - lambda state: np.array( - [state[0] - 1.0, state[1] - 2.0], - dtype=float, - ), - np.array([0.5, 0.5], dtype=float), - bounds=( - np.array([0.0, 0.0], dtype=float), - np.array([3.0, 3.0], dtype=float), - ), - jac_sparsity=algebraic_sparsity, - tr_solver="lsmr", - ) - if ( - not algebraic.success - or not np.isfinite(algebraic.x).all() - or not np.allclose(algebraic.x, np.array([1.0, 2.0]), atol=1.0e-8) - ): - raise RuntimeError("SciPy algebraic warm-up did not complete successfully.") - - root = brentq(lambda value: value - 0.5, 0.0, 1.0) - if abs(root - 0.5) > 1.0e-12: - raise RuntimeError("SciPy scalar root warm-up returned an invalid result.") - - # Compile the cached v3 XSD through the same public validation path. The - # intentionally incomplete document is never accepted or persisted. + The model-specific executable is generated when a model is submitted. + """ + from app.simulation.native_codegen.build import toolchain from app.system_xml import validate_system_xml_document + if os.name != "nt": + raise RuntimeError("Native v1 currently supports Windows x64 build packaging only.") + toolchain() validate_system_xml_document(b"") @@ -114,6 +65,10 @@ def warm_up_simulation_runtime() -> SimulationWarmupReport: global _WARMUP_REPORT + from app.simulation.backends import numeric_engine_name + + engine = numeric_engine_name() + with _WARMUP_LOCK: if _WARMUP_REPORT is not None: return _WARMUP_REPORT @@ -126,7 +81,7 @@ def warm_up_simulation_runtime() -> SimulationWarmupReport: started = perf_counter() try: - _run_numerical_warmup() + _run_native_warmup() except MemoryError: raise except Exception as exc: @@ -142,7 +97,8 @@ def warm_up_simulation_runtime() -> SimulationWarmupReport: duration_ms=(perf_counter() - started) * 1000.0, ) LOGGER.info( - "Simulation runtime warm-up completed in %.1f ms.", + "Simulation runtime warm-up (%s) completed in %.1f ms.", + engine, _WARMUP_REPORT.duration_ms, ) return _WARMUP_REPORT diff --git a/app/system_xml.py b/app/system_xml.py index 7bc511b..01dfcce 100644 --- a/app/system_xml.py +++ b/app/system_xml.py @@ -12,8 +12,8 @@ from lxml import etree from app.simulation.core.ports import PortDefinition from app.simulation.performance import profile_phase from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec -from app.simulation.solvers.solver import SolveIVPConfig -from app.simulation.systems.generic import ( +from app.simulation.config import SolveIVPConfig +from app.simulation.sampling import ( SimulationSampleTimeError, simulation_sample_times, ) @@ -25,7 +25,7 @@ SYSTEM_XML_MAX_BYTES = 5 * 1024 * 1024 SYSTEM_XML_V3_SCHEMA_PATH = ( Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v3.xsd" ) -SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"} +SUPPORTED_SOLVER_METHODS = {"RK45", "BDF"} @dataclass(frozen=True) diff --git a/bat/start-backend.bat b/bat/start-backend.bat index 42fea6a..032ec51 100644 --- a/bat/start-backend.bat +++ b/bat/start-backend.bat @@ -5,6 +5,7 @@ for %%I in ("%~dp0..") do set "REPO_ROOT=%%~fI" title SystemSimulationApp FastAPI - 127.0.0.1:8000 set "PYTHON_EXE=%REPO_ROOT%\.venv-win\Scripts\python.exe" +if not defined SIMULATION_NUMERIC_ENGINE set "SIMULATION_NUMERIC_ENGINE=native" if not exist "%PYTHON_EXE%" ( echo [ERROR] Python virtual environment was not found: diff --git a/bat/start-backend.sh b/bat/start-backend.sh index 08511d8..1d5b181 100755 --- a/bat/start-backend.sh +++ b/bat/start-backend.sh @@ -5,6 +5,7 @@ set -u SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd -P)" PYTHON_EXE="${SYSTEM_SIMULATION_PYTHON:-$REPO_ROOT/.venv/bin/python}" +export SIMULATION_NUMERIC_ENGINE="${SIMULATION_NUMERIC_ENGINE:-native}" if [[ "$PYTHON_EXE" != /* ]]; then PYTHON_EXE="$REPO_ROOT/$PYTHON_EXE" diff --git a/constraints/python312-direct.txt b/constraints/python312-direct.txt index 65b1d4b..bcda2ac 100644 --- a/constraints/python312-direct.txt +++ b/constraints/python312-direct.txt @@ -6,7 +6,5 @@ # pins in a clean CPython 3.12 environment when intentionally upgrading them. fastapi==0.141.1 lxml==6.1.1 -numpy==2.5.2 pydantic==2.13.4 -scipy==1.18.0 uvicorn==0.52.3 diff --git a/constraints/python312-linux-x86_64.lock b/constraints/python312-linux-x86_64.lock index 26cd8df..a3b8afd 100644 --- a/constraints/python312-linux-x86_64.lock +++ b/constraints/python312-linux-x86_64.lock @@ -16,12 +16,10 @@ h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da5 httptools==0.8.0 --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2 idna==3.18 --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 lxml==6.1.1 --hash=sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818 -numpy==2.5.2 --hash=sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8 pydantic==2.13.4 --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba pydantic-core==2.46.4 --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce python-dotenv==1.2.3 --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc -scipy==1.18.0 --hash=sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2 starlette==1.6.0 --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c typing-extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 typing-inspection==0.4.4 --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 diff --git a/docs/other/C内核第一版实施记录.md b/docs/other/C内核第一版实施记录.md new file mode 100644 index 0000000..57f0d23 --- /dev/null +++ b/docs/other/C内核第一版实施记录.md @@ -0,0 +1,66 @@ +# C 内核第一版实施计划与记录 + +> 本文记录迁移当时的阶段结果;Python 参考实现已于 2026-09-10 退役,当前入口与保留范围见 [退役记录](Python数值实现退役记录.md)。 + +2026-09-09。状态:第一版已完成并验证;尚未将全组件库切换为原生默认后端。 + +## 分步计划 + +| 顺序 | 工作 | 第一版交付 | 状态 | +| --- | --- | --- | --- | +| 1 | 冻结边界与模型合同 | skill-test 的组件能力表、输入与运行设置、共同 XML 前端 | 已完成 | +| 2 | 开发 C 组件内核与模型编译器 | 由 XML 拓扑生成状态、端口、计算顺序及系统 C 源码;不依赖固定组件编号 | 已完成 | +| 3 | 接入原生积分执行 | 独立 EXE,RK45 与 CVODE BDF;进度、取消、错误、结果和编译缓存 | 已完成 | +| 4 | 接入 Python 并精简重复代码 | 统一后端分派;保留模型定义、有效的 Python 参考与兼容路径,移除被替代的重复入口逻辑 | 已完成 | +| 5 | 验证第一版 | 同状态 Python/C 方程对照、拓扑通用性、API 回归、skill-test 完整求解与计时 | 已完成 | + +## 本版范围 + +- 首批覆盖 skill-test 使用的 12 类组件:氦气介质、PNCH023、PNCH012、PNVO001、PNPL01、STEP0、UD00、FORC、PNRP17、MECMAS21、F000、LSTP00A。具体支持的参数模式由编译器检查,不将组件类型覆盖误报为所有模式覆盖。 +- Python 负责 XML 校验、网络检查、代码生成、编译缓存与任务管理;生成程序中的物性、流量、机械、积分与事件处理不回调 Python。 +- 用户要求本轮不调整雅可比:现有 Python 雅可比、着色与切向代码保持不变;原生 BDF 使用本机 SUNDIALS 7.4.0 默认稠密数值雅可比,不添加自定义雅可比或矩阵优化。 +- `test/skill-test.json` 是前次 RK45 / maxStep=0.001 s 测试副本;下载原件是 BDF / maxStep=0.02 s。本轮分别标注两组设置,不混用时间;仿真均为 0–10 s,仅数值验证,无绘图。 +- C 组件从现有 Python 方程及已验证的 C 原型迁移,核对单位、符号、事件和模式;不直接复制原型中的固定模型连接或编号。 +- 本版能力以显式后端开关启用。不支持的模型在积分开始前给出明确诊断;默认 Python 路径继续服务尚未迁移的组件。 + +## 后续版本 + +1. 扩大到管路、分支节点、更多物性和摩擦/接触模式;增加通用代数环执行。 +2. 完善参数与结构分离缓存、跨平台发布和更大模型验收。 +3. 用户另行授权后开展雅可比、稀疏矩阵和局部导数优化。 + +## 实施结果 + +### 代码与精简内容 + +- 新增 `app/simulation/native_codegen/`:JSON/XML 输入适配、能力检查、模型 C 生成、构建缓存、隔离运行与 CLI。 +- 新增 `native/components/`、`native/runtime/`、`native/include/`:物性/阀流量/信号/接触/端挡原语,RK45 与 CVODE BDF,事件、采样和结果输出。 +- `app/main.py` 改为共用后端分派,移除入口内重复的求解器配置与 Python 系统构造。 +- 将通用结果和准备错误合同从 `generic.py` 抽到 `results.py`,C 结果适配不再为获取数据类型而导入整个 Python 求解系统;原模块保留导入兼容性。 +- 活动跟踪器增加原生汇总进度接口,直接接收真实 RHS/接受步计数,避免每次 C 求值都回调 Python。 +- Python 组件定义、仍在使用的示例 API 和参考求解器继续保留;没有把被其他模型调用的代码误判为无用代码删除。 +- 现有 Python 雅可比、着色、切向和矩阵求解逻辑未修改。 + +### 验证 + +执行 `python -m unittest tests.test_native_codegen tests.test_generic_system_xml_simulation tests.test_system_xml_v3 -q`,33 项测试全部通过。覆盖组件方程、逆向流、端挡试探、组件重命名/重排、增加独立支路、缓存、移除 Python PATH 后独立运行 EXE、取消、XML API 和既有 Python 路径。 + +当前 Python 的完整 0–10 s RK45 对照已重跑;175 个变量、502 个时间点及求值次数、接受步、事件数全部与 C 对应,序列比较采用 `rtol=1e-9, atol=1e-9`。所有样本有限、系统质量守恒检查通过。 + +首轮通用接口测试中的毫秒级心跳用例曾出现一次 Windows 时序波动;独立复核通过,最终上述完整 33 项测试也通过。未修改生产心跳行为或放宽断言。 + +### skill-test 求解时间 + +同机、预热一次后测三次取中位数,关闭轨迹采样与绘图;初始化、编译、结果投影和文件写入不计入求解时间。 + +| 配置 | 最大步长 | rtol | 求解中位时间 | RHS 次数 | +| --- | --- | --- | ---: | ---: | +| 原生 RK45 | 0.001 s | 1e-6 | 0.2947735 s | 60,200 | +| 原生 CVODE BDF | 0.02 s | 1e-6 | 0.0222788 s | 3,915 | +| 原生 CVODE BDF,收紧精度检查 | 0.02 s | 1e-8 | 0.0152101 s | 3,097 | + +BDF 默认设置相对更细 RK45 参考的最大压力偏差约 2.38 kPa、温度偏差 0.0863 K、位移偏差 2.04e-7 m;收紧到 `rtol=1e-8` 后分别降至约 3.09 Pa、2.83e-5 K、1.72e-8 m。本例收紧误差限同时减少了求值/拒绝步,不能据此推广“越严格越快”。 + +第一版尚未声称覆盖全部物理模式或与 Amesim 所有曲线一致。BDF 使用另一种积分实现,不能把它的耗时与 Python RK45 相除后归因于语言。 + +使用说明见 [native/README.md](../../native/README.md),完整证据见 [第一版测试报告](../../test/native-v1/report.md)。 diff --git a/docs/other/C内核组件库覆盖记录.md b/docs/other/C内核组件库覆盖记录.md new file mode 100644 index 0000000..dcd57d0 --- /dev/null +++ b/docs/other/C内核组件库覆盖记录.md @@ -0,0 +1,63 @@ +# C 内核组件库覆盖记录 + +> 本文记录迁移当时的阶段结果;Python 参考实现已于 2026-09-10 退役,当前入口与保留范围见 [退役记录](Python数值实现退役记录.md)。 + +日期:2026-09-10。范围是当前注册表中的全部模型:Amesim 库 22 类,实验库 5 类。这里的“覆盖”指项目现有模型方程有对应的 C 实现,不代表复刻完整 Siemens 元件库,也不代表任意拓扑、任意刚性模型均能快速求解。 + +## 实施步骤 + +1. 核对注册表、模型版本、全部活动端口、状态与结果变量,建立显式 C 合同白名单。 +2. 补充空气物性、阀模式、管路、节点、换热、摩擦与反弹限位的 C 公式。 +3. 扩展系统 C 生成:压力分组、串联阻力闭合、焓传播、常系数力/流量约束消元、刚性质量合并、兼容管路容腔状态投影。 +4. 对照 Python 的状态导数和全部结果变量,执行 RK45/BDF 事件回归及实际 JSON 测试。 + +四步已经落实到代码。完整运行中的超时限制单独记录,不作为通过项。 + +## 组件清单 + +| 类别 | 已覆盖模型 | 主要补充 | +| --- | --- | --- | +| 介质,2 类 | Ideal Air Medium、Helium Medium | 编译期选择空气理想气体或氦气 Peng–Robinson C 物性;包括能量反解、密度、等熵因子和黏度。 | +| 气动边界,1 类 | PNPL01 | 零流量、连接侧焓传播。 | +| 信号,2 类 | STEP0、UD00 | 跳变、分段斜坡、循环与事件分段;纯信号模型也可运行。 | +| 机械,6 类 | F000、FORC、MECMAS21、LSTP00A、LMECHN1、PNRP17 | 力/速度/位移约束、可变端口节点、刚性质量合并、活塞容积与压力力。 | +| 气腔,2 类 | PNCH023、PNCH012 | 固定/可变容积、质量与内能守恒、换热与边界功。 | +| 阀,3 类 | PNOR001、PNVO001 Fixed、PNVO001 Signal | 面积/Cv/Kv 模式、限幅开度、正反向流动、近等压平滑及诊断。 | +| 管路,4 类 | PNL00R、PNL0001、PNL0002、PNL0003 | 无储能/单容腔/双容腔、摩阻与层流过渡、换热、流动诊断、兼容容腔合并。 | +| 气动节点,2 类 | PN3NODE2、P4NODE2 | 等压与质量守恒,端口 2 温度参考、平滑能量分配。 | +| 实验组件,5 类 | Cylinder、Tank、ResistivePipe、Orifice、Tee | 储能、Darcy 阻力、孔口和混合节点。 | + +模型版本逐项固定于 `app/simulation/native_codegen/contracts.py`。回归检查注册表与此表完全一致;新增模型、未移植的版本和自定义子类会明确拒绝,不会因继承了一个 Python 类就被视为已移植。 + +## 参数模式与物理范围 + +- MECMAS21:`stoptype=1/2/3/4`,塑性、柔性、反弹及无限位;黏性摩擦、库仑摩擦和风阻使用现有方程。反弹使用 `restcoeff`/`restdvel`,刚性组冲击按活动端挡的最小反弹系数处理,塑性端挡优先。 +- LSTP00A:接受 `stiffmode=1/2` 和两种 `discContactOption`。当前 Python 的接触力始终使用 `kcont`、`rcont`、`Pdis`,C 保持一致。几何模式参数目前未生成另一套刚度公式。 +- 当前 Python 中 MECMAS21 的 `theta`、`fstick`、`strib`、`frictionType` 等参数没有对应的额外动力学分支,C 沿用其实际行为;不能把接受这些参数解释为已增加重力斜面、静摩擦锁止或完整 Stribeck 模型。 +- 管路继续沿用当前 Python 的公式:PNL0003 是 Darcy 压降反解;PNL00R/0001/0002 是已有的可压缩摩擦流及过渡拟合。未更换为另一套 Amesim 库公式。 + +## 系统生成与运行 + +Python 读取/校验 XML、创建模型对象、整理连接与常量并生成 C。质量/内能初值也在预处理阶段计算。EXE 内执行物性反解、流量/焓闭合、机械力计算、状态导数、积分、事件定位和采样,不调用 Python 方程。 + +保留原来针对简单储能拓扑的紧凑生成路径;新增 `extended.py` 覆盖完整目录及扩展连接。两条路径都是 C。编译器不使用 Python 数值后端作为隐式备用。 + +兼容的 PNL0001/PNL0003 固定容腔可直接连接:初始压力/温度需一致,同组质量与内能按体积投影,导数按体积分配。独立气腔之间的无阻力直连仍按项目既有规则拒绝;无压力锚点、欠定机械约束及无法收敛的闭合也明确报错。 + +积分器仍为原生 RK45、CVODE BDF;没有修改 ODE 雅可比、稀疏性或线性求解策略。C 网络中的常系数连接消元属于系统组装。扩展编译器上限为 1024 状态、16384 输出;平台仍为 Windows x64。 + +## 验证及重要限制 + +回归入口: + +```powershell +.venv-win/Scripts/python.exe -m unittest tests.test_native_catalog tests.test_native_codegen tests.test_simulation_warmup -v +``` + +27 项回归通过,包含空气/氦气、正反向流动、阀参数模式、管路换热/光滑壁、节点、串联阻力、兼容管路容腔、机械摩擦/柔性限位、接触和信号。状态导数及输出对照容限为 `rtol=2e-8, atol=2e-7`,原 skill fixture 的更严格对照仍保留。RK45/BDF 均验证了反弹解析解和纯信号运行。另补充四个质量块刚性相连的解析校验:总质量 10 kg、外力 30 N,C 得到共享加速度 3 m/s²;该测试通过。 + +实际 JSON、运行日志与报告存于 `test/native-catalog/`。最终构建的 `skill-test` 完成 10 s 仿真,RK45、最大步长 0.001 s、rtol=1e-7;预热后一次求解计时为 0.322429 s,进程墙钟 0.743252 s。它是功能回归期间的单次记录,不是严格性能基准。`test-mql-8` 的 132 个状态导数、1784 个输出量在初始、信号变化及保存的瞬态状态上与 Python 对照通过。 + +**test-mql-8 的完整 BDF 运行未通过**:`rtol=1e-7`、最大步长 0.02 s、60 s 求解超时,推进至约 0.0153307 s;记录为受控超时,不是完成 10 s 的耗时。当前 CVODE 多次重建默认数值雅可比,属于仍需处理的完整系统求解性能问题。本次保留用户要求的雅可比策略,不用降低刚度、放宽误差或更换物理方程来掩盖它。之前固定模型的 C BDF/NDF 程序与当前通用 CVODE 是不同求解实现,其既有完成时间不能直接套用到这里。 + +Python 参考方程暂保留以支撑这些数值对照,且模型描述、公共配置、XML/API 与旧示例仍有依赖。组件 C 覆盖完成并不等于可以整目录删除 Python。 diff --git a/docs/other/C语言数值内核实施计划与可行性评估.md b/docs/other/C语言数值内核实施计划与可行性评估.md index 03be492..76bc657 100644 --- a/docs/other/C语言数值内核实施计划与可行性评估.md +++ b/docs/other/C语言数值内核实施计划与可行性评估.md @@ -2,6 +2,7 @@ > 文档状态:待评审实施方案 > 建立日期:2026-09-02 +> 补充日期:2026-09-09;第 10 节结合最新实测,细化 XML → 系统专用 C → 原生 EXE 路线。前九节保留原阶段方案与历史评估;本次仅补充设计,未完成生产内核替换。 > 适用分支:`model-development` > 关联文档:[求解器性能优化任务清单](求解器性能优化任务清单.md)、[后端求解逻辑与效率优化调研](后端求解逻辑与效率优化调研.md) > 范围:后端数值执行内核;不包含前端重写,也不主张把整个 Python 后端改写为 C @@ -404,3 +405,137 @@ Numba 可以在完整数组 IR 后用于 1–2 周的架构验证,但不作为 6. 评审通过后,再建立最小 C ABI 和纵向切片。 第一批的退出条件不是“已经写了多少 C”,而是:当前基准可信、同一计算能被无回调 IR 完整表达、差异能定位到具体阶段和槽位。只有达到这个条件,后续 C 工作才具有可预测的收益和可控的回滚成本。 + +## 10. 2026-09-09 补充:XML 自动生成系统专用 C / EXE + +### 10.1 新证据与本次目标 + +用户本次希望解析 XML 后,自动生成对应系统的 C 求解程序。建议把“模型专用代码生成 + 原生积分运行”作为目标架构;开发时仍保留“相同输入逐阶段比较”和“相同 SciPy 积分器仅替换完整 RHS/Jacobian”的诊断阶段,用来分别测量执行语言与积分算法的影响。后者是验证手段,不是最终交付边界。 + +最新证据不能把速度差异全部归因于 Python: + +- 先前特定模型的完整 C EXE 求解仍约 44.82 s;C 剖析中模型求值占 97.07%,雅可比构造占 87.17%。雅可比包含模型求值,两项不能相加。 +- 该 C 版本采用 132 状态的逐列差分,每次雅可比约需 133 次完整 RHS,累计 124,089 次。这个结论针对测试 C 实现;当前 Python 引擎已经有稀疏结构、着色和部分半解析路径,不能把它也描述成始终采用全稠密差分。 +- Amesim 仅关闭 optimized solver,进程 CPU 从 2.890625 s 增至 13.890625 s,结果文件字节、函数/雅可比计数、成功步数均相同。这证明执行优化层本身很重要,但没有揭示其专有优化实现。 +- C 管流中的固定次数二分及频繁未收敛退出是已知热点,不能作为成熟组件内核直接复制到生产生成器。 + +依据:[C 剖析报告](../../test/native-mql8-profile/performance-analysis.md)、[Amesim 优化开关对照](../../test/native-mql8-profile/amesim-optimizer-comparison.json)。这是特定模型对照,不是受控的生产 Python/C 语言收益测量,也不保证新引擎达到 Amesim 的速度。 + +### 10.2 最终执行边界 + +```text +网页导出 System XML + → Python:协议校验、组件版本/参数/连接校验 + → ModelIR:方程/可信组件原语、连续状态、代数变量、事件、依赖 + → 结构编译:合并等价变量、确定计算顺序、划分代数环、生成稀疏结构 + → C emitter:model.c + model.h + manifest.json + → 编译并链接:组件 C 库 + 原生积分运行库 + → 独立 native worker / 模型 EXE:完成初始化、积分、事件、数值采样 + → Python:读取结果、任务状态、现有 API 编码 +``` + +编译和 EXE 均在后端机器执行。Python 可以继续完成低频管理工作;一次积分中的 RHS、代数闭合、物性、Jacobian、Newton 和事件处理全部留在原生进程,不能在每次求值时回调 Python。 + +首个交付可生成模型专用 EXE,便于独立运行、复现和隔离崩溃。之后如实测编译或启动成本成为问题,再用通用 worker 加载缓存模型 DLL;DLL 同样加载在隔离进程中。原生运行时与组件库预先编译,每个新拓扑只生成和编译模型装配部分。 + +### 10.3 XML 不包含完整方程,必须建立 C 组件库 + +现行 XML 保存组件类型、版本、参数和连接,不包含各组件的物理公式。自动编译依赖一份可信映射: + +```text +(model_type, model_version, 支持的结构选项) + → 参数校验 + 状态/端口布局 + 方程或 C kernel + 局部导数 + 事件/reset +``` + +`app/simulation/registry.py` 的注册定义可以继续作为编译前端的数据来源,逐个增加原生能力描述。普通公式可用受限表达式 IR 表达,同时生成数值计算和导数;复杂物性与非线性管流使用经过验证的手写 C 原语,并声明完整输入输出及导数合同。不要依赖自动翻译任意 Python 类。 + +例如“气室 A—管道—气室 B”编译后固定为状态和参数数组下标:求两边物性、算这根管道的流量、按端口方向累加质量/能量导数。运行时不再寻找组件对象、端口名称或连接边。系统专用代码可以直接调用 `pipe_kernel(...)`,无需把物性库和积分器源码重复展开进每一个模型。 + +现有 `EquationResidual` 保存变量名称及已经计算出的 `value`,并没有保存完整公式表达式;`causal_ir.py` 提供部分结构基础,但其执行绑定仍有 Python 回调。因此需要补齐完整数值 IR。当前 `app/simulation/ir/` 仅发现缓存文件,不能视为已有可维护的完整 IR 源码。 + +`test/native-mql8-bdf/generate_model.py` 是验证原型:仍限定特定阀动作、机械组件编号、部分参数和连接形式,并拒绝某些循环闭合。正式编译器应由拓扑和能力合同推导这些内容,不能直接把这份脚本改成 XML 读入便宣称通用。 + +### 10.4 编译器要提前完成的计算 + +| 编译工作 | 运行时收益或正确性要求 | +| --- | --- | +| 合并等压、等速度等别名变量;组装流量/力平衡 | 消除多余未知量,统一端口方向与 SI 单位 | +| 对方程和未知量做匹配,将相互依赖部分划成小块 | 能顺序计算的直接计算,仅对真正代数环迭代;无法配平时定位到组件 | +| 固定状态、代数变量、参数、工作区和输出下标 | 每次求值使用连续数组,避免字典、字符串查找和临时分配 | +| 编译 stream 图及受温度影响的物理子网 | 保留现有无环传播和局部闭合能力,避免退回每轮全网重算 | +| 计算各入口的依赖集合与所有支持模式的稀疏结构 | RHS、事件、Jacobian 和输出按需计算;求 RHS 不自动附带所有输出 | +| 提取几何常量、公共表达式和物性状态包 | 参数不变的部分只初始化一次;相同精确输入下共享结果 | +| 声明事件、离散模式、缓存失效和状态复位 | 阀门跳变、流向切换、接触不依赖碰巧足够小的积分步长 | + +跨积分调用的缓存必须按输入和模式有效性管理,不能仅以时间为键;同一时间可能存在多个 Newton 试探状态或雅可比扰动。试探工作区与已接受状态分开,拒绝步与事件回退不得提交试探产生的模式或缓存。 + +### 10.5 积分库与代数环的选择 + +当前引擎在 RHS 内闭合代数变量,对外给出 ODE。第一版原生全流程建议使用 **SUNDIALS CVODE 的 BDF 模式**。CVODE 提供 C 接口、1–5 阶变阶变步长 BDF,以及稠密、带状、稀疏线性求解接口。它的 Adams/BDF 由调用方选择,不能将其描述成 Amesim/LSODA 那样自动切换两种方法。[CVODE 数学方法](https://sundials.readthedocs.io/en/latest/cvode/Mathematics_link.html) + +生成器负责模型方程、结构和导数,成熟积分库负责步长/阶数、误差控制和求解器历史。不要为每份 XML 重新生成一套 BDF 算法。先锁定可复现的 SUNDIALS 版本、C 接口、构建工具和依赖;Windows 用原生 C 构建链产出 EXE,Linux 对应 ELF 程序。 + +稀疏矩阵可配合 KLU,但先提供正确稀疏 Jacobian,再根据模型规模比较稀疏与稠密线性代数;132 状态案例的 LU 仅占约 2.19%,只换 LU 库难以改变总体耗时。[CVODE 线性求解选择](https://sundials.readthedocs.io/en/latest/cvode/Mathematics_link.html) + +如果某类代数环难以可靠消去,可另行建立 `F(t, y, ydot) = 0` 的 DAE 后端,评估 C 接口的 IDA。此时必须处理代数变量、一致初始化和系统指数;高指数约束不能直接交给 IDA 期待自动解决。[IDA 官方介绍](https://sundials.readthedocs.io/en/latest/ida/Introduction_link.html) + +消去代数变量后,Jacobian 不能只拼接相邻组件的直接偏导。对 + +```text +ydot = f(t, y, z) +0 = g(t, y, z) +``` + +在当前光滑分支、`g_z` 可逆时,需通过线性求解得到 `g_z * dz/dy = -g_y`,再组装 `J = f_y + f_z * dz/dy`,无需显式计算逆矩阵。这样才包含“状态变化 → 代数环重新平衡 → 导数变化”的影响。结构编译也要考虑此过程产生的新依赖。 + +第一版可用保守结构着色差分建立正确基线,再逐类增加局部解析导数或自动微分。旧实测的非零图和 27 色只是某次运行观察,不能直接当作全部流向/接触模式的可靠结构。迭代求解器的导数应针对其收敛方程,不宜把固定次数迭代轨迹直接当作物理方程导数。 + +### 10.6 项目接入点和生成产物 + +| 位置 | 具体接入方式 | +| --- | --- | +| `app/system_xml.py` | 复用现有校验和 `SystemXmlDocument`;补充明确的误差设置传递合同 | +| `app/main.py:compile_system_xml_network` | 抽取共同的版本、参数、介质与连接规范化前端;避免 Python/C 分别解释模型 | +| `app/simulation/solvers/causal_ir.py` 与现有分块/stream 计划 | 复用结构分析结果,转换为无 Python 回调的数值计划 | +| 建议新增 `app/simulation/native_codegen/` | 完整数值 IR、组件原生能力注册、结构编译、C emitter、编译缓存 | +| 建议新增 `native/runtime/`、`native/components/`、`native/include/` | CVODE 适配、代数求解、组件与物性原语、版本化 C ABI | +| 建议新增 `app/simulation/backends/` | Python 与原生 worker 的统一运行接口、结果适配和诊断 | +| `app/main.py:_run_system_xml_simulation_profiled` | 在现有任务/进度/取消接口内选择后端,保持结果变量标识和单位一致 | + +每个模型的缓存目录建议包含: + +```text +/ + model.c / model.h # 状态、端口布局和模型专用计算入口 + manifest.json # 来源、版本、布局、组件映射、支持能力、构建哈希 + model.exe # 链接原生运行库的可执行模型 +``` + +每次运行另建目录,保存数值参数、运行选项、结果和诊断。模型 ABI 至少区分 `initialize`、`rhs/residual`、`jacobian`、`roots`、`apply_event`、`outputs`,通用运行库提供 `run`。RHS 返回导数,事件函数返回零点函数值,输出函数只在需要物理投影时调用。每个运行拥有独立 context,禁止共享可变全局状态。 + +缓存键包括规范化拓扑、组件/介质实现版本、结构参数、IR/ABI 版本、精度、平台、编译器和编译选项。普通数值参数以数组传入,时长、容差、最大步长与输出间隔由运行选项传入;这些修改通常无需重新编译。改变端口数量、状态数量或方程分支结构的参数必须触发重新编译。若把某个普通参数特化进源码,也必须将其值纳入缓存键。 + +生成器只输出可信原语与合法数值,用户组件 ID 通过映射表定位,不作为任意 C 源码或编译命令片段。未支持的组件、模式或算法在编译/启动阶段明确报告;强制原生测试不得静默改用 Python。 + +### 10.7 精度、采样与性能口径 + +本次检查发现两个实际差异: + +- 网页入口 `app/main.py` 的 `rtol` 当前写死为 `1e-6`;此前测试 C 为 `1e-7`。XML v3 尚无 `rtol/atol` 字段,应通过明确的协议演进同时更新 XSD、解析、导出和执行设置,不可只修改某一后端。 +- 当前 `tests/data/test-mql-8.xml` 仍为 `maxStep=0.001`,而此前 JSON 测试副本使用过 `1e30` 和 `0.02`。后续应由同一份规范化模型与独立运行配置驱动比较,不能用文件同名推断设置相同。 + +`rtol` 相同也不等于总体精度相同:需同时固定每类状态的 `atol`、单位/缩放、代数闭合容差、初始化及事件处理。首阶段保持状态定义和方程一致;改变质量/内能为压力/温度等状态形式属于单独的数值方案。 + +`sampleStep` 与内部积分步长分开:使用积分库的插值能力取得输出采样点,不为每个输出点重新启动求解器。已知不连续时刻仍要显式分段,状态事件要定位零点并执行复位/重启。`maxStep=1e30` 只放宽上限,不会使求解器忽略精度或事件自动迈大步。 + +主指标单列求解墙钟与求解 CPU;XML 解析、代码生成、冷编译、初始化、输出投影、文件写入和 HTTP 序列化分别计时。性能模式只计推进所需的求值与事件定位,不计绘图及额外结果投影;正确性模式另行采样曲线验证。积分器要求的初始 RHS/Jacobian 也计入求解工作量,避免从计时中漏掉。 + +### 10.8 推荐实施顺序 + +1. **冻结一份共同输入和运行配置。** 以 `test-mql-8.xml` 为首个完整目标,列出其全部组件类型/模式、公式与原生能力;对齐当前 Python、旧 C 原型和 Amesim 的参数、单位、状态与事件差异。 +2. **完成一条闭环支路的完整 IR 和 C kernel。** 以同一 `t/y/mode/parameters` 比较初始化、物性、闭合、RHS、Jacobian 与事件左右值;覆盖正常、反向流、近零压差和接触状态。 +3. **生成该支路的 C 并链接 CVODE,跑通独立 EXE。** 同时以“相同 SciPy + Python/C RHS”作为执行开销对照,分开判断 C 迁移收益与更换积分器的影响。 +4. **覆盖首个完整模型,并接入现有 API 的显式原生选项。** 将当前 Python 的结构消元、stream 局部执行、事务回滚和稀疏能力迁入;优先修复管流收敛与雅可比重复求值,再考虑低占比线性代数优化。 +5. **验证通用性和再逐步默认启用。** 用组件重命名、连线变化、不同支路数量、反向流和其他受支持参数模式证明生成器不依赖原测试拓扑;按现有物理验收合同检查 Amesim 投影、事件时刻和守恒残差。 + +首个交付应能由任意命名、符合已声明支持范围的 XML 稳定生成可运行 EXE,同时报告实际后端、求解器版本、模型/二进制哈希、计时及求值/迭代计数。本次文档没有执行这项迁移,也没有新增性能测试;提速倍率需待上述共同输入对照完成后报告。 diff --git a/docs/other/Python数值实现退役记录.md b/docs/other/Python数值实现退役记录.md new file mode 100644 index 0000000..7df5e33 --- /dev/null +++ b/docs/other/Python数值实现退役记录.md @@ -0,0 +1,26 @@ +# Python 数值实现退役记录(2026-09-10) + +网页与 CLI 统一采用 C 数值内核。删除旧 Python 积分器及模型数值实现;Python 保留输入校验、模型元数据、C 生成、构建缓存、任务与结果管理。 + +## 删除与保留 + +- 删除 `app/simulation/solvers/`、`systems/generic.py`、Python 状态/物性求值、组件流量/受力/导数方法、物性缓存及对应的逐次物性计时。 +- 删除旧固定算例求解器、旧 Python 基准执行脚本和依赖它们的比较脚本;保留 Amesim 结果读取器与历史数值文件。 +- `config.py` 保存配置与进度,`sampling.py` 保存采样合法性检查,`results.py` 保存结果合同。 +- 组件 Python 文件保留参数、端口、显示、结果、方程结构以及几何预处理。方程结构声明不再包含运行时残差值。 +- 气体质量/能量初值由 C `native_medium_init()` 计算,兼容实验气瓶/贮箱原有初值定义。 +- 后端默认且仅支持 `native`;`native-c` 是别名,`python` 会明确报错。旧固定算例 HTTP 接口返回 410,统一 XML 接口继续使用。 +- 后端运行依赖移除 NumPy/SciPy。NumPy 仅用于回归断言,列在 `requirements-test.txt`。 +- C RK45/CVODE BDF 和既有雅可比策略保持不变。无时间信号机械模型的生成代码补充未使用参数声明,修复严格编译警告导致的构建失败。 + +## 回归依据 + +删除前从旧 Python 内核捕获 `tests/data/native-python-reference.json`:50 个网络、112 组状态的初值、导数和输出。覆盖空气/氦气、正反向流、换热、节点、串联阻力、管路容腔、机械模式、信号、重命名/重排及重复支路;删除后由生成的 EXE 对照冻结值,容差 `rtol=2e-8, atol=2e-7`。另有四质量块解析校验、反弹与纯信号 RK45/BDF、XML/任务取消和接口测试。 + +历史 Python MQL 基准的原始 XML/JSON 移到 `tests/baselines/simulation/test_mql_8/sources/`,保留原始哈希与仿真设置,不用新内核覆盖历史结果。当前 `tests/data/test-mql-8.json` 和 XML 同步采用已选择的最大步长 0.02 s。 + +`skill-test` 对应的 `tests/data/native-skill-test.xml` 完成 0–10 s:RK45、最大步长 0.001 s、rtol 1e-7、不记录轨迹。预热后单次纯求解 **0.381517 s**,60224 次导数求值、10022 个接受步、14 个拒绝步。该次同时进行其他回归工作,不作为新的性能优劣结论。 + +当前版本仍仅提供 Windows x64 原生构建。此前已发现大型 `test-mql-8` 的 CVODE BDF 完整仿真过慢;本次删除旧实现不解决该问题,不能据组件覆盖或短时测试宣称其长时仿真已通过。详见 [组件覆盖记录](C内核组件库覆盖记录.md)。 + +本地生成的 EXE、DLL、计时日志在 `/test/`,不提交编译产物。源码和冻结基准进入 Git,可重新生成。 diff --git a/docs/standard/component-model-authoring-spec-v1.md b/docs/standard/component-model-authoring-spec-v1.md index 07cd4f9..f5c8de1 100644 --- a/docs/standard/component-model-authoring-spec-v1.md +++ b/docs/standard/component-model-authoring-spec-v1.md @@ -1,15 +1,15 @@ # 组件模型建模规范 v1 -状态:已在 `experimental` 临时组件库实施 -适用对象:人工开发者、代码生成工具和 AI 编程助手 +状态:2026-09-10 更新为 Python 声明、C 数值实现 +适用对象:人工开发者、代码生成工具和 AI 编程助手 配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md) ## 1. 文档目标 -本文档规定一个 Python 仿真元件应如何创建、修改、测试和注册。完成后的模型必须 +本文档规定采用 Python 元数据与 C 数值内核的元件应如何创建、修改、测试和注册。完成后的模型必须 同时满足四个使用方: -1. 求解器能够实例化模型并调用方程。 +1. C 编译器能够读取模型声明、生成系统代码并调用 C 方程。 2. System XML 能够根据稳定类型找到模型。 3. React Flow 能够自动显示图标、端口和参数。 4. 结果页面能够根据结构化元数据展示变量。 @@ -43,11 +43,7 @@ ### 2.3 新增内部模型 -仅供固定算例或研究代码使用、不进入前端目录的模型,不加入 `library.py`。这类模型 -应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。 - -当前示例是 -[`app/simulation/examples/testmodel/dynamic_pipe.py`](../../app/simulation/examples/testmodel/dynamic_pipe.py)。 +不进入前端目录的研究模型不加入 `library.py`。需要执行时仍应编写 C 内核并显式纳入编译器支持合同;不要恢复旧 Python 求解路径。 ### 2.4 新增物理域 @@ -111,7 +107,6 @@ app/simulation/components/experimental/junctions/tee.py ```python MODEL_TYPE = "example_component" MODEL_VERSION = "1.0.0" -PRESSURE_FLOW_DEPENDS_ON_STREAM = False PORTS = (...) PARAMETERS = (...) RESULT_VARIABLES = (...) @@ -138,62 +133,11 @@ def create( ## 6. 基类选择 -### 6.1 `AlgebraicComponent` +`AlgebraicComponent` 表示没有积分状态的元件;`DynamicComponent` 表示有积分状态的元件;`ThermodynamicVolumeComponent` 提供标准 `m,U,p,T,rho,u,h` 结果声明。这些基类只描述模型,不再实现数值求值方法。 -适用于没有积分状态、由当前端口变量和参数直接决定残差的元件,例如: +构造函数负责参数校验、几何预处理和端口注册。介质对象保存物性常量与模型选择。状态初值、流量、焓、受力和导数必须在 C 中计算。 -- 孔板 -- 阀门 -- 阻性管段 -- 理想三通 - -至少实现: - -- 构造函数和端口注册。 -- `create()`。 -- `pressure_flow_equation_residuals()`。 -- 需要传递 stream 变量时实现 `update_stream_outflows()`。 - -实现 `update_stream_outflows()` 或 `update_flow_temperature_references()` 的公开模型, -还应在该公开类自身显式声明 `PRESSURE_FLOW_DEPENDS_ON_STREAM`:构成压力/流量方程 -会读取这些 hook 写入的焓或温度引用时设为 `True`,否则设为 `False`。省略声明、 -声明非法值或由自定义子类仅继承父类声明时,求解器会保守使用全网热流闭合;不要 -为了获得分块加速而错误声明 `False`。 - -`pressure_flow_equation_residuals()` 返回的每条 `EquationResidual.variables` 必须完整 -列出该残差实际读取的全部代数端口量(`p/m_flow/x/v/f`),不能只写“主要变量”。 -求解器会用这份声明编译稀疏 Jacobian 和独立方程块;漏写依赖可能让有限差分方向 -不完整。仓库内置组件会接受结构与数值依赖回归,外部自定义组件当前仍保守使用 -全网 dense 回退,直到具备同等的依赖验证边界。 - -### 6.2 `ThermodynamicVolumeComponent` - -适用于包含质量和能量状态的气体容腔,例如: - -- 气瓶 -- 贮箱 -- 有容积的管段 - -至少实现: - -- `get_state_vector()`。 -- `set_state_vector()`。 -- `refresh_thermodynamic_ports()`。 -- `state_derivative_from_ports()`。 -- `pressure_flow_equation_residuals()`。 - -该基类已经提供标准热力学组件结果: - -```text -m, U, p, T, rho, u, h -``` - -除非物理含义不同,不要重新复制这组结果声明。 - -### 6.3 其他基类 - -如果现有基类不能表达模型,应先评估是否缺少一种通用组件能力。不要为了一个模型 -直接把专用判断塞入 `SimulationNetwork` 或求解器。 +用 `EQUATIONS` 或 `equation_definitions()` 声明结构化连接约束,返回 `EquationDefinition`,包括关系、变量和归属,不包含运行时残差值。`__MODEL__` 占位符由基类替换为实例名。该声明供网络结构展示与检查使用,不替代 C 方程或构建支持白名单。 ## 7. 端口建模规范 @@ -319,16 +263,7 @@ ResultVariableDefinition( ) ``` -声明后必须在 `component_result_values()` 返回同名值: - -```python -def component_result_values(self) -> Mapping[str, float]: - return { - "pressure_drop": self.port_a.p - self.port_b.p, - } -``` - -声明集合和返回键必须一致。 +声明的每个输出必须在 C 生成器的输出布局中有对应值。测试应核对实际 EXE 输出键与 `result_variable_metadata()` 一致,不再实现 Python `component_result_values()`。 ### 9.2 端口结果 @@ -407,168 +342,20 @@ def create( `create()` 不应重复实现参数默认值和边界校验,也不能静默修改传入参数。 -## 12. 方程实现要求 +## 12. C 方程实现要求 -模型方程必须满足: +1. 在 `native/components/kernels.c` 及 `native/include/kernels.h` 实现物性或元件数值公式。 +2. 在 `native_codegen/extended.py` 注册状态、端口、参数、初始化与输出映射;符合简单拓扑的模型还应核对 `compiler.py` 快速路径。 +3. 在 `native_codegen/contracts.py` 声明支持版本,不允许仅注册 Python 模型就声称具备 C 求解能力。 +4. 当前状态与试探状态分离,求值不能覆盖已接受状态。无效物性、欠定连接和不收敛必须明确失败。 +5. 信号跳变和限位事件接入 C 运行库;不能改动刚度、阻尼或容差来隐藏数值错误。 +6. 保持 SI 单位和端口流入为正,核对逆流、质量/能量守恒及边界状态。 -- 残差形式统一为“期望等式左侧减右侧”。 -- 每条 `EquationResidual` 使用稳定、可定位的 `id`。 -- `variables` 列出该残差实际涉及的端口量或状态。 -- `role` 与方程主要约束的物理角色一致。 -- 对零压差、零流量和反向流动给出有限结果。 -- 必要正则化必须有物理解释,并通过边界测试保护。 -- 不得用画布坐标、连接线方向或组件名称决定方程。 +## 13. 模型实现示例 -### 12.1 可因果执行的残差语义 +可参照 [气瓶声明](../../app/simulation/components/experimental/storage/cylinder.py)、[气腔声明](../../app/simulation/components/amesim/storage/chambers.py)、[C 内核](../../native/components/kernels.c) 与 [系统 C 生成器](../../app/simulation/native_codegen/extended.py)。完整开发顺序见 [组件目录说明](../../app/simulation/components/example.md)。 -后端只会对经过结构门控的内置模型启用完全因果执行。除完整声明 -`variables` 外,这些模型还必须遵守以下可执行语义: - -- `role="effort", relation="state"` 的残差写成 - `端口 effort - 状态给定值`,被约束的端口量系数必须为 `+1`。 -- `role="effort", relation="equal"` 的残差写成两个同类 effort 的差。 -- `role="flow", relation="sumToZero"` 按“流入组件为正”的约定求和,待消元 - flow 的系数必须为 `+1`。 -- `role="flow", relation="constitutive"` 写成 - `待消元 flow - 本构计算值`,待消元 flow 的系数必须为 `+1`。 -- `pressure_flow_equation_values()` 必须是无副作用的只读计算,返回顺序和长度 - 必须与 `pressure_flow_equation_residuals()` 的编译结果永久一致;不得在求残差时 - 修改端口、状态或活动集缓存。 - -求解器仍会在首次闭合、离散事件之后和固定周期执行完整残差审计。结构不满足、 -运行时覆盖不完整或审计不通过时,会立即熔断到原有残差/非线性求解路径。现场诊断 -时可在启动进程前设置 `SIMULATION_CAUSAL_FAST_PATH=0`,一键关闭该优化而不改变模型 -文件。 - -动态模型还必须: - -- 状态向量长度稳定。 -- `get_state_vector()` 和 `set_state_vector()` 互为逆操作。 -- 状态导数满足质量和能量守恒约定。 -- 初始化默认值能够产生有限介质状态。 - -## 13. 可复制的代数模型模板 - -下面是一个符合当前规范的两端口代数阻力模板。复制后必须根据真实物理模型修改 -类型、参数、方程、名称和测试,不能只改类名就注册。 - -```python -from __future__ import annotations - -from collections.abc import Mapping -from math import sqrt - -from app.simulation.core.base import AlgebraicComponent -from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec -from app.simulation.core.equations import EquationResidual -from app.simulation.core.metadata import ParameterDefinition -from app.simulation.core.medium import IdealGasMedium -from app.simulation.core.ports import PortDefinition - - -class ExampleRestriction(AlgebraicComponent): - MODEL_TYPE = "example_restriction" - MODEL_VERSION = "1.0.0" - PRESSURE_FLOW_DEPENDS_ON_STREAM = False - PORTS = ( - PortDefinition.pneumatic("port_a", nominal_role="bidirectional"), - PortDefinition.pneumatic("port_b", nominal_role="bidirectional"), - ) - PARAMETERS = ( - ParameterDefinition( - name="K", - label="流量系数", - quantity="flow_coefficient", - unit="kg/(s*Pa^0.5)", - default=1e-5, - minimum=0.0, - ), - ) - RESULT_VARIABLES = () - DISPLAY = ComponentDisplaySpec( - label="示例阻力元件", - library_id="experimental", - category_id="flow", - symbol="generic", - ports=( - PortDisplaySpec("port_a", "left", order=10), - PortDisplaySpec("port_b", "right", order=20), - ), - order=90, - ) - - def __init__(self, name: str, K: float = 1e-5) -> None: - super().__init__(name) - self.set_parameter_values({"K": K}) - self.K = K - self.port_a = self.register_declared_port("port_a") - self.port_b = self.register_declared_port("port_b") - - @classmethod - def create( - cls, - *, - name: str, - medium: IdealGasMedium, - parameters: Mapping[str, float], - ) -> ExampleRestriction: - return cls(name=name, K=parameters["K"]) - - def pressure_flow_equation_residuals( - self, - ) -> tuple[EquationResidual, ...]: - pressure_difference = self.port_a.p - self.port_b.p - expected_flow = ( - self.K - * sqrt(abs(pressure_difference)) - * (1.0 if pressure_difference > 0.0 else -1.0) - if pressure_difference != 0.0 - else 0.0 - ) - return ( - EquationResidual( - id=f"{self.name}:mass_flow_balance", - owner="component", - owner_id=self.name, - relation="sumToZero", - variables=( - f"{self.name}.port_a.m_flow", - f"{self.name}.port_b.m_flow", - ), - role="flow", - value=self.port_a.m_flow + self.port_b.m_flow, - ), - EquationResidual( - id=f"{self.name}:pressure_flow_relation", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.port_a.p", - f"{self.name}.port_b.p", - f"{self.name}.port_a.m_flow", - ), - role="flow", - value=self.port_a.m_flow - expected_flow, - ), - ) - - def update_stream_outflows( - self, - connected_h: Mapping[str, float], - ) -> None: - self.port_a.h_outflow = connected_h["port_b"] - self.port_b.h_outflow = connected_h["port_a"] -``` - -真实现有模型可参考: - -- 储能元件: - [`cylinder.py`](../../app/simulation/components/experimental/storage/cylinder.py) -- 阻性元件: - [`orifice.py`](../../app/simulation/components/experimental/flow/orifice.py) -- 多端口连接元件: - [`tee.py`](../../app/simulation/components/experimental/junctions/tee.py) +Python `create()` 只创建经校验的描述对象;C `model_init()` 生成质量、能量及机械状态,`model_eval()` 计算导数和输出。两者通过生成的状态/参数布局关联。 ## 14. 注册模型 @@ -597,11 +384,11 @@ models=( 2. 默认参数创建测试。 3. 参数边界测试。 4. 端口与显示布局一致性测试。 -5. 关键方程残差测试。 +5. C 方程与独立解析解或冻结参考值对照。 6. 零流量或反向流动测试。 7. 目录输出测试。 8. 最小 XML 编译测试。 -9. 能进入通用求解器的模型,再添加短时仿真测试。 +9. 使用 C RK45/BDF 的短时仿真与事件测试。 推荐先运行: diff --git a/docs/update-log/更新日志-2026-09-09.md b/docs/update-log/更新日志-2026-09-09.md new file mode 100644 index 0000000..3fa4890 --- /dev/null +++ b/docs/update-log/更新日志-2026-09-09.md @@ -0,0 +1,23 @@ +# 更新日志 2026-09-09 + +## 21:37 + +- 完成第一版 C 数值后端:由规范化 XML 自动生成模型 C 代码,链接独立 RK45/CVODE BDF 程序,支持 skill-test 使用的 12 类组件及已声明模式。 +- 接入现有 XML 仿真 API、编译缓存、独立进程、进度、取消与错误结果;共用 Python 后端入口和结果合同,保留仍在使用的 Python 参考及其他模型。未调整现有雅可比算法。 +- 33 项相关测试通过。skill-test 的 RK45 全量结果与当前 Python 严格对照通过;三次纯求解中位时间为 RK45 0.2948 s、BDF 0.02228 s。BDF 收紧误差限后的收敛检查通过,详细偏差和限制见第一版报告。 +- 程序、生成源码、验证数据与报告保存在 `test/native-v1/`;新增使用说明和分步实施记录。 + +## 22:19 + +- 在当前网页真实复现并修复 C 仿真完成后读取缺失 `pressureFlow.maxScaledResidual` 的错误。C 结果补齐采样点数和状态数,前端兼容可选的 Python 诊断以及旧 C 后端缺少的计数字段;结果可以保存和刷新恢复。 +- 确认用户改后的默认后端实际为 `native-c`。3 项浏览器回归、1 项原生 API 诊断回归、TypeScript 检查及前端生产构建通过。 +- 完成 skill-test 的 EXE、HTTP、生产网页及当前开发网页对照:两种方法共 96 次运行(含预热),完整数值结果逐点一致;5 次中位数中,RK45 纯求解 0.3196 s、完整 EXE 0.6326 s、当前网页 1.1846 s;BDF 分别为 0.02206 s、0.3673 s、0.8043 s。 +- 另以固定结果流回放隔离进度交互开销:10 组配对中位数约增加 189.6 ms 的浏览器主线程任务时间,主要落在开发构建下工作台与组件库的重复 JSX 创建。该时间与后台执行重叠,不能直接作为额外等待时间相加。 +- 详细记录和复现脚本位于 `test/native-web-20260909/`。本次未变更求解器、雅可比或进度交互性能结构。 + +## 默认 C 启动与 Python 依赖核对 + +- 统一默认内核选择,后端脚本默认设为 `native`,启动日志明确显示所选内核;显式 `python` 覆盖仍用于旧模型和数值对照。 +- 启动预热按内核分支执行:C 模式检查工具链与 XML Schema,不再执行 Python/SciPy 数值预热;工具链失败不会静默回退。 +- 10 项启动/默认 API 回归测试通过。额外在全新进程中移除内核环境变量,验证真实应用 lifespan 与 skill-test XML 执行:默认 native、未加载 scipy.integrate,成功计算至 10 s,502 采样点,12 状态。记录位于 `test/native-default-startup/result.json`。 +- 核对并记录 Python 保留范围:C 编译仍依赖组件对象、注册表、公共配置与 XML 采样校验,旧示例和未覆盖模型仍依赖 Python 数值实现,未整批删除现有文件。 diff --git a/docs/update-log/更新日志-2026-09-10.md b/docs/update-log/更新日志-2026-09-10.md new file mode 100644 index 0000000..e666ac3 --- /dev/null +++ b/docs/update-log/更新日志-2026-09-10.md @@ -0,0 +1,15 @@ +# 更新日志 2026-09-10 + +- 完成当前注册组件库的 C 方程覆盖:22 类 Amesim 公开组件(含两种介质定义)和 5 类实验组件,新增显式模型版本白名单。 +- 补充空气物性、管路阻力/换热、阀参数模式、气动和机械节点、摩擦、柔性及反弹限位;扩展串联压力闭合、焓传播、刚性质量合并及兼容管路容腔投影。 +- 数值循环全部位于生成的 C 程序中;保留原简单拓扑的紧凑生成路径、默认 C 启动及 Python 参考后端。未调整 ODE 雅可比策略。 +- 27 项回归通过,另增加的四质量块合并解析校验通过。`skill-test` 完成 10 s,最终 RK45 回归的单次求解时间 0.322429 s;EXE 与记录位于 `test/native-catalog/skill-final-rk45/`。 +- `test-mql-8` 在初始和瞬态状态下的 132 个导数、1784 个输出通过 Python 对照;完整 CVODE BDF 在 60 s 限额内仅推进至 0.0153307 s,明确记录为超时,完整运行性能仍待改善。 +- 详细范围、已有参数的实际物理行为与连接限制见 `docs/other/C内核组件库覆盖记录.md`。 + +## 随后完成:退役旧 Python 数值实现 + +- 删除旧 Python 积分器、模型数值方法、物性缓存和固定算例求解器;Python 层保留配置、参数、校验、编译、进度与结果合同,C 同时负责状态初值。 +- 固化 50 个网络、112 组 Python 参考状态,删除后用独立 EXE 回归;补充无需 NumPy/SciPy 导入的后台与代码生成检查。 +- 原生 RK45 完成 `skill-test` 10 s,最大步长 0.001 s、rtol 1e-7,预热后纯求解 0.381517 s;前端生产构建通过。 +- 当前生效范围与限制以 `docs/other/Python数值实现退役记录.md` 为准,前面的 Python 参考后端描述属于当日较早阶段。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 244a4a2..5af3af7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -461,16 +461,16 @@ type SimulationResult = { final: Record; series: Record; diagnostics: { - pressureFlow: { + pressureFlow?: { solveCount: number; maxScaledResidual: number; maxEvaluationsPerSolve: number; }; - stream: { + stream?: { maxIterationsPerSolve: number; }; stateCount: number; - sampleCount: number; + sampleCount?: number; }; }; @@ -1259,7 +1259,7 @@ export function observeSimulationActivityWatchdog( }, }; } -const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"]; +const SUPPORTED_SOLVER_METHODS = ["BDF", "RK45"]; const simulationConfigLabels: Record = { t_start: "起始时间", t_stop: "结束时间", @@ -7374,7 +7374,7 @@ function FlowWorkbench() { ) => { const resultStatus = result.status ?? (result.success ? "completed" : "failed"); const sampleCount = Math.min( - result.diagnostics.sampleCount, + result.diagnostics.sampleCount ?? result.series.time?.length ?? 0, result.series.time?.length ?? 0, ); const hasUsableSamples = sampleCount >= 2; @@ -7403,6 +7403,11 @@ function FlowWorkbench() { failed: `仿真异常终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`, } as const; const statusMessage = statusMessages[resultStatus]; + const maxScaledResidual = result.diagnostics.pressureFlow?.maxScaledResidual; + const residualMessage = + typeof maxScaledResidual === "number" && Number.isFinite(maxScaledResidual) + ? `;最大方程残差 ${formatNumber(maxScaledResidual)}` + : ""; if (resultStatus === "completed") { setSimulationProgress({ @@ -7415,7 +7420,7 @@ function FlowWorkbench() { }); appendConsoleEntry( "success", - `${statusMessage},可在“结果”页签查看;最大方程残差 ${formatNumber(result.diagnostics.pressureFlow.maxScaledResidual)}`, + `${statusMessage},可在“结果”页签查看${residualMessage}`, ); } else { setSimulationProgress((current) => ({ @@ -7444,7 +7449,10 @@ function FlowWorkbench() { id: `simulation-${Date.now()}`, createdAt: new Date().toISOString(), project: cloneValue(project), - result: cloneValue(result), + result: cloneValue({ + ...result, + diagnostics: { ...result.diagnostics, sampleCount }, + }), }; setResultSnapshot(nextSnapshot); storeResultSnapshot(nextSnapshot); diff --git a/frontend/src/SimulationResultsView.tsx b/frontend/src/SimulationResultsView.tsx index 71961ca..99e03e0 100644 --- a/frontend/src/SimulationResultsView.tsx +++ b/frontend/src/SimulationResultsView.tsx @@ -146,12 +146,12 @@ type ResultPayload = { stateTransitionTimes?: number[]; }>; }; - pressureFlow: { + pressureFlow?: { solveCount: number; maxScaledResidual: number; maxEvaluationsPerSolve: number; }; - stream: { + stream?: { maxIterationsPerSolve: number; }; stateCount: number; diff --git a/frontend/tests/e2e/native-result.spec.ts b/frontend/tests/e2e/native-result.spec.ts new file mode 100644 index 0000000..4f47a93 --- /dev/null +++ b/frontend/tests/e2e/native-result.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; +import { expandSimulationConsole, prepareApp, resultSnapshot, wideProject } from "./fixtures"; + +for (const diagnosticsMode of ["native", "legacy-native", "python"] as const) { + test(`completed ${diagnosticsMode} results can be published and restored`, async ({ page }) => { + await prepareApp(page); + const diagnostics = diagnosticsMode === "python" + ? resultSnapshot.result.diagnostics + : { backend: "native-c", stateCount: 1, + ...(diagnosticsMode === "native" ? { sampleCount: 3 } : {}) }; + await page.route("**/api/system-xml/simulate-stream", route => route.fulfill({ + contentType: "application/x-ndjson", + body: JSON.stringify({ event: "result", progress: 100, phase: "completed", + result: { ...resultSnapshot.result, diagnostics } }) + "\n", + })); + await page.goto("/"); + await page.locator('input[type="file"]').setInputFiles({ + name: "native-result.json", mimeType: "application/json", + buffer: Buffer.from(JSON.stringify({ ...wideProject, edges: [...wideProject.edges, { + id: "close-test-loop", source: "generic_sensor_4", target: "generic_sensor_1", + sourceHandle: "port_b", targetHandle: "port_a", data: { isContactEdge: false }, + }] })), + }); + await expect(page.getByRole("textbox", { name: "工程", exact: true })).toHaveValue(wideProject.name); + await page.getByRole("button", { name: "运行仿真", exact: true }).click(); + await expandSimulationConsole(page); + const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true }); + await expect(consolePanel).toContainText("仿真完成,已生成新的结果,共 3 个采样点"); + await expect(consolePanel).not.toContainText("仿真失败"); + if (diagnosticsMode === "python") { + await expect(consolePanel).toContainText("最大方程残差 0"); + } else { + await expect(consolePanel).not.toContainText("最大方程残差"); + } + const saved = await page.evaluate(() => JSON.parse(sessionStorage.getItem("system-simulation-flow:latest-result") ?? "null")); + expect(saved.result.diagnostics.sampleCount).toBe(3); + expect(saved.result.series).toEqual(resultSnapshot.result.series); + await page.reload(); + await page.getByRole("tab", { name: "结果", exact: true }).click(); + await expect(page.getByText("3 个采样点", { exact: false }).first()).toBeVisible(); + }); +} diff --git a/native/README.md b/native/README.md new file mode 100644 index 0000000..cf9bbab --- /dev/null +++ b/native/README.md @@ -0,0 +1,76 @@ +# C 数值后端 + +后端 Python 校验 XML、检查连接、生成系统专用 C 并编译;独立 EXE 执行完整数值循环。原生运行不调用 Python。当前发布构建支持 Windows x64。 + +## 启用网页后端 + +当前默认使用 `native`。也可在启动后端的 PowerShell 中显式设置: + +```powershell +$env:SIMULATION_NUMERIC_ENGINE = 'native' +$env:SIMULATION_NATIVE_CC = 'F:/Projects/mingw64/bin/gcc.exe' +$env:SUNDIALS_ROOT = 'F:/Anaconda/Library' +.venv-win/Scripts/python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +沿用 `/api/system-xml/simulate` 与 `/api/system-xml/simulate-stream`。`native` 遇到不支持的组件或方法时报告错误,不静默切换。旧 Python 后端已退役,显式选择 `python` 会报错。 + +`bat/start-backend.bat`、`bat/start-backend.sh` 及其 `start-all` 上层入口默认设置 `native`,保留明确指定的环境变量覆盖。直接通过 uvicorn 启动也使用相同的默认选择。启动日志显示所选内核;C 模式只检查工具链与 XML Schema,不再预热 SciPy 积分/代数求解器。工具链检查失败会记录启动诊断,编辑器仍可使用,提交仿真时会明确报错,不会自动调用 Python 数值后端。 + +通用诊断包含 `stateCount`、`sampleCount`。`pressureFlow`、`stream` 属于 Python 后端的可选诊断;C 后端不报告未计算的方程残差,网页仅在该值存在时显示它。 + +原生运行库已在本机 GCC 8.1 / SUNDIALS 7.4.0 验证。构建需要对应 C 头文件、导入库和 DLL,单纯安装 Python 包不能代替这些文件。构建器根据环境变量或当前 Python 基础环境查找 SUNDIALS,根据环境变量或 PATH 查找 GCC。 + +## 独立生成与运行 + +```powershell +.venv-win/Scripts/python.exe -m app.simulation.native_codegen tests/data/native-skill-test.xml --output-dir test/native-v1/example-run --runs 3 --solve-only +``` + +输入支持 XML,或带普通算术表达式的工程 JSON。JSON 普通数字已经是 SI 值;算术表达式按编辑器所选单位换算,例如 `3.14*10**2/4 mm2` 转为 `0.0000785 m2`。较复杂的表达式应先在网页导出 XML。CLI 不执行任意 Python/JavaScript 表达式。 + +输出目录包含 `input.xml`、`model-manifest.json`、`program/`、预热和各次运行结果,以及 `summary.json`。`program/` 包含 EXE、生成的模型 C/头文件、SUNDIALS DLL 和依赖声明,可以复制整目录独立运行: + +```powershell +test/native-v1/example-run/program/model.exe --method RK45 --start 0 --stop 10 --sample-step 0.02 --max-step 0.001 --rtol 1e-6 --output test/native-v1/standalone-result.json +``` + +EXE 不需要 Python、SciPy、XML 或原工程文件。DLL 需要与 EXE 一同保留。默认运行设置是 RK45、0–10 s、最大步长 0.001 s;按需要传入运行选项。 + +`--solve-only` 关闭轨迹采样,只输出最终状态与诊断。`solveSeconds` 是程序内部数值求解墙钟时间,包含求解必需的 RHS 和事件定位,排除模型初始化、结果投影和文件写入;`processWallSeconds` 另含进程启动与结果处理。预热一次后报告三次求解的中位数。 + +## 能力与限制 + +- 已实现当前注册的 27 类组件:22 类 Amesim 公开组件(含空气、氦气两种介质定义)和 5 类实验组件。完整清单及验证说明见 [组件覆盖记录](../docs/other/C内核组件库覆盖记录.md)。介质定义在编译期选择对应的 C 物性函数。 +- 管路覆盖 PNL00R、PNL0001/2/3;阀覆盖 PNOR001、固定/信号开度 PNVO001 及面积/Cv/Kv 模式;连接件覆盖 PN3NODE2、P4NODE2、LMECHN1。支持串联阻力的压力求解、节点焓混合及温度参考、刚性质量合并、兼容管路容腔的等密度状态投影。 +- MECMAS21 支持现有 Python 方程中的摩擦、风阻、柔性限位和 `stoptype=1/2/3/4`,含反弹系数与速度阈值;气腔和管路支持换热。LSTP00A 接受两种刚度模式及接触力符号模式,严格沿用当前组件方程。已有参数中尚未参与 Python 方程的物理效应不会在 C 端凭空补造,详见覆盖记录。 +- 扩展编译器上限 1024 状态、16384 输出;无连续状态的信号系统使用隐藏常量状态驱动输出。气动网络必须有压力状态锚点;独立气腔之间不能无阻力直接相连。兼容固定管路容腔是已实现的合并例外。闭合未收敛或方程欠定时明确失败,不静默回退。 +- 支持原生 RK45 与 CVODE BDF。CVODE 继续使用默认数值雅可比和稠密线性求解;本次删除不改变 C 求解器的雅可比策略。 +- 默认 `rtol=1e-6`、气体状态 `atol=1e-8`、机械状态 `atol=1e-12`。CLI 可覆盖 rtol;本版不支持自定义 atol 或 first_step。不同积分器相同局部容差不保证全局曲线误差完全相同。 +- 时间信号显式分段,塑性/反弹端挡用稠密插值定位并重启。试探 RHS 不修改已接受状态。柔性接触沿用现有分段力公式,不改变刚度或阻尼来提速。 +- 每任务独立进程,支持进度、取消及超时。进程崩溃不会作为成功返回,受控失败保留最后接受状态。 +- 编译缓存位于 `app/data/native-builds/`,校验模型、组件合同、源码、编译选项及依赖哈希。本版参数特化入 C,修改模型数值参数会重新编译;修改时间、步长、rtol、采样选项可复用 EXE。 + +## 代码职责 + +- `app/simulation/native_codegen/input.py`:CLI 输入适配。 +- `contracts.py`:逐组件 C 实现版本白名单,新增模型或版本不会自动视为已支持。 +- `compiler.py` / `extended.py`:能力检查、状态/输出布局、连接分组、常系数约束消元、C 生成。保留已验证的简单拓扑快速生成路径,两条路径均只运行 C 数值代码。 +- `build.py` / `runner.py`:编译缓存、依赖打包、隔离执行与结果适配。 +- `native/components/kernels.c`:空气/氦气物性、阀与管路流量、信号、摩擦接触和端挡原语。 +- `native/runtime/`:RK45、CVODE 适配、事件定位、采样与 CLI。 +- `app/simulation/backends.py` / `results.py`:共用执行入口与结果合同。 + +执行 `.venv-win/Scripts/python.exe -m unittest tests.test_native_catalog tests.test_native_codegen tests.test_simulation_warmup` 可验证全部注册合同、空气/氦气正反向流、换热/摩擦、节点和串联闭合、刚性与管路状态合并、反弹事件、纯信号系统、缓存、独立 EXE、取消及默认 C 的 XML 接口。新增组件库对照测试需要可用的 C 工具链。 + +## Python 保留范围 + +- `app/main.py`、`system_xml.py`:HTTP、XML 校验与结果返回。 +- `registry.py`、`core/`、`components/`、`systems/network.py`:参数、端口、介质常量、输出与方程结构声明;不执行模型数值公式。 +- `native_codegen/`:生成系统 C、构建缓存、管理独立进程。 +- `config.py`、`sampling.py`、`results.py`:配置、进度、采样合法性和结果合同。 +- `performance.py`:Python 编排阶段计时,求解耗时读取 C 报告。 + +`solvers/`、`systems/generic.py`、旧示例、Python 物性/流量/机械公式和物性缓存已删除。数值回归读取 `tests/data/native-python-reference.json` 的 50 个网络、112 组冻结参考状态,不需要 Python 求解器。旧算法及历史对照资料可从 Git 历史找回。 + +测试安装:`python -m pip install -r requirements-test.txt`。当前完整数值测试要求 Windows x64、GCC 与 SUNDIALS;Linux 尚不提供 EXE 构建。 diff --git a/native/THIRD_PARTY_NOTICES.txt b/native/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..5617d66 --- /dev/null +++ b/native/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,89 @@ +RK45 tableau, dense-output coefficients and step-control logic are adapted +from the locally installed SciPy integrate/_ivp/rk.py and common.py. +The implementation is rewritten in C; SciPy is not linked or required. + +Copyright (c) 2001-2002 Enthought, Inc. 2003, SciPy Developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +SUNDIALS 7.4.0 + +BSD 3-Clause License + +Copyright (c) 2002-2025, Lawrence Livermore National Security and Southern Methodist University. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +This work was produced under the auspices of the U.S. Department of +Energy by Lawrence Livermore National Laboratory under Contract +DE-AC52-07NA27344. + +This work was prepared as an account of work sponsored by an agency of +the United States Government. Neither the United States Government nor +Lawrence Livermore National Security, LLC, nor any of their employees +makes any warranty, expressed or implied, or assumes any legal liability +or responsibility for the accuracy, completeness, or usefulness of any +information, apparatus, product, or process disclosed, or represents that +its use would not infringe privately owned rights. + +Reference herein to any specific commercial product, process, or service +by trade name, trademark, manufacturer, or otherwise does not necessarily +constitute or imply its endorsement, recommendation, or favoring by the +United States Government or Lawrence Livermore National Security, LLC. + +The views and opinions of authors expressed herein do not necessarily +state or reflect those of the United States Government or Lawrence +Livermore National Security, LLC, and shall not be used for advertising +or product endorsement purposes. \ No newline at end of file diff --git a/native/components/kernels.c b/native/components/kernels.c new file mode 100644 index 0000000..096d237 --- /dev/null +++ b/native/components/kernels.c @@ -0,0 +1,289 @@ +/* Helium Peng-Robinson and compressible-orifice kernels. + * Ported from the project's Python physical equations; validated independently + * against Python at ordinary, reverse-flow and contact trial states. + */ +#include "kernels.h" +#include +#include + +#define RU 8.31446261815324 +#define MOLAR_MASS 0.004002602 +#define TC 5.1953 +#define PC 227460.0 +#define OMEGA (-0.382) +static const double PI=3.1415926535897932384626433832795; +static const double pr_a=.457235583*RU*RU*TC*TC/PC; +static const double pr_b=.07779607*RU*TC/PC; +static const double kappa=.37464+1.54226*OMEGA-.26992*OMEGA*OMEGA; +static const double rg=RU/MOLAR_MASS; + +static double cube_root(double x) { return x==0 ? 0 : copysign(pow(fabs(x),1.0/3.0),x); } +static void attraction(double T, double *a, double *da, double *dda) { + double tr=T/TC, sr=sqrt(tr), base=1+kappa*(1-sr); + *a=pr_a*base*base; + *da=pr_a*(-base*kappa/(TC*sr)); + *dda=pr_a*kappa/(2*TC*TC)*(kappa/tr+base/(tr*sr)); +} +static double z_factor(double p,double T) { + double a,da,dda; attraction(T,&a,&da,&dda); + double A=a*p/(RU*RU*T*T), B=pr_b*p/(RU*T); + double ca=-(1-B),cb=A-3*B*B-2*B,cc=-(A*B-B*B-B*B*B); + double pp=cb-ca*ca/3,qq=2*ca*ca*ca/27-ca*cb/3+cc; + double disc=pow(qq/2,2)+pow(pp/3,3),off=-ca/3,roots[3]; int n; + if(disc>1e-14) { roots[0]=cube_root(-qq/2+sqrt(disc))+cube_root(-qq/2-sqrt(disc))+off;n=1; } + else if(fabs(disc)<=1e-14) { double u=cube_root(-qq/2);roots[0]=2*u+off;roots[1]=-u+off;n=2; } + else { if(pp>=0) return NAN; double radius=2*sqrt(-pp/3); + double arg=(3*qq/(2*pp))*sqrt(-3/pp),theta=acos(fmax(-1,fmin(1,arg)))/3; + for(int i=0;i<3;i++) { roots[i]=radius*cos(theta-2*PI*i/3)+off; } + n=3; + } + double z=-INFINITY;for(int i=0;iB && isfinite(roots[i])) z=fmax(z,roots[i]); + return isfinite(z)?z:NAN; +} +static double density(double p,double T) { return MOLAR_MASS/(z_factor(p,T)*RU*T/p); } +static double log_volume(double rho) { + double v=MOLAR_MASS/rho,sq=sqrt(2.0); + return log((v+(1+sq)*pr_b)/(v+(1-sq)*pr_b))/(2*sq*pr_b*MOLAR_MASS); +} +static double u_departure(double T,double rho) { + double a,da,dda;attraction(T,&a,&da,&dda);return (T*da-a)*log_volume(rho); +} +static double h_departure(double p,double T) { + double a,da,dda;attraction(T,&a,&da,&dda); + double z=z_factor(p,T),B=pr_b*p/(RU*T),sq=sqrt(2.0); + double dep=RU*T*(z-1)+(T*da-a)*log((z+(1+sq)*B)/(z+(1-sq)*B))/(2*sq*pr_b); + return dep/MOLAR_MASS; +} +static double u_ideal(double T) { return rg*(1.5*T-745.375); } +static double h_ideal(double T) { return rg*(2.5*T-745.375); } +static double temperature_u(double u) { return (u/rg+745.375)/1.5; } +static double temperature_h(double h) { return (h/rg+745.375)/2.5; } +static double pressure_rho(double T,double rho) { + double v=MOLAR_MASS/rho,a,da,dda;attraction(T,&a,&da,&dda); + if(v<=pr_b || T<=0) return NAN; + return RU*T/(v-pr_b)-a/(v*(v+pr_b)+pr_b*(v-pr_b)); +} +static NativeGas gas_properties(double m,double U,double V) { + NativeGas g;g.rho=m/V;g.u=U/m; + double T=fmax(temperature_u(g.u),2.2); + for(int i=0;i<16;i++) { double next=fmax(temperature_u(g.u-u_departure(T,g.rho)),2.2); + int done=fabs(next-T)<=1e-10*fmax(T,1);T=next;if(done) break; } + g.T=T;g.p=pressure_rho(T,g.rho);g.h=h_ideal(T)+h_departure(g.p,T);return g; +} +static double temperature_ph(double p,double h) { + double T=fmax(temperature_h(h),2.2); + for(int i=0;i<16;i++) { double next=fmax(temperature_h(h-h_departure(p,T)),2.2); + int done=fabs(next-T)<=1e-10*fmax(T,1);T=next;if(done) break; }return T; +} +static void local_isentropic(double p,double T,double *factor,double *exponent) { + double rho=density(p,T),v=MOLAR_MASS/rho,a,da,dda;attraction(T,&a,&da,&dda); + double d=v*(v+pr_b)+pr_b*(v-pr_b); + double dpT=RU/(v-pr_b)-da/d; + double dpR=(-RU*T/pow(v-pr_b,2)+a*2*(v+pr_b)/(d*d))*(-MOLAR_MASS/(rho*rho)); + double cv=1.5*rg+T*dda*log_volume(rho); + double cp=cv+T*dpT*dpT/(rho*rho*dpR),gamma=cp/cv; + *factor=p/(rho*dpR*gamma);*exponent=p*(gamma-1)/(gamma*T*dpT); +} +static double isentropic(double p,double T,double pd) { + double f,e,fd,ed;local_isentropic(p,T,&f,&e);if(pd>=p) return f; + double Td=fmax(T*pow(fmax(pd/p,1e-12),e),2.2); + local_isentropic(fmax(pd,1),Td,&fd,&ed);return .5*(f+fd); +} +static double subsonic_cm(double r,double gamma,double rho,double T,double p) { + return sqrt(fmax(2/(1-gamma)*rho*T/p*(pow(r,2*gamma)-pow(r,1+gamma)),0)); +} +static void valve(double p,double pd,double T,double *cm,double *vel) { + p=fmax(p,1);pd=fmax(fmin(pd,p),0);T=fmax(T,1); + double g=fmax(1e-9,fmin(1-1e-9,isentropic(p,T,pd))),rho=fmax(density(p,T),1e-12); + double r=fmax(pd/p,0),critical=pow(2*g/(g+1),1/(1-g)),eff; + if(r<=critical) { eff=critical;*cm=sqrt(2/(1+g)*rho*T/p)*pow(2*g/(g+1),g/(1-g));*vel=sqrt(2/(1+g)*p/rho); } + else { eff=r;*cm=subsonic_cm(r,g,rho,T,p);*vel=sqrt(fmax(2/(1-g)*p/rho*(1-pow(r,1-g)),0)); } + double ref=subsonic_cm(.9999,g,rho,T,p); + if(*cm>0 && ref>0) { double smooth=tanh(fmax(12*fabs(*cm/ref)*log(eff)/log(.9999),0));*cm*=smooth;*vel*=smooth; } +} + +int native_gas_init(double p, double T, double volume, double *mU) { + if (!(p > 0 && T >= 2.2 && volume > 0)) return 0; + double rho = density(p, T); + mU[0] = rho * volume; + mU[1] = mU[0] * (u_ideal(T) + u_departure(T, rho)); + return isfinite(mU[0]) && isfinite(mU[1]) && mU[0] > 0; +} + +int native_gas(double m, double U, double volume, NativeGas *gas) { + if (!(m > 0 && volume > 0) || !isfinite(U)) return 0; + *gas = gas_properties(m, U, volume); + return gas->p > 0 && isfinite(gas->p) && isfinite(gas->h); +} + +int native_orifice(double p1, double p2, double h1, double h2, + double cq_area, double opening, double *flow, + double *cm, double *velocity) { + int forward = p1 >= p2; + double p = forward ? p1 : p2, pd = forward ? p2 : p1; + double T = temperature_ph(fmax(p, 1), forward ? h1 : h2); + valve(p, pd, T, cm, velocity); + double sign = forward ? 1 : -1; + *velocity *= sign; + *flow = opening == 0 || fabs(p1-p2) <= 1e-8 ? 0 : + sign * cq_area * opening * fmax(p, 1) * *cm / sqrt(fmax(T, 1)); + if (opening == 0) *velocity = 0; + return isfinite(*flow) && isfinite(*cm) && isfinite(*velocity); +} + +double native_contact(double penetration, double velocity, double stiffness, + double damping, double pdis, int signed_force) { + if (penetration <= 0) return 0; + double fraction = pdis > 0 ? -expm1(-penetration / pdis) : 1; + double force = stiffness * penetration + fraction * damping * velocity; + return signed_force == 1 ? force : fmax(force, 0); +} + +void native_stop_motion(double x, double v, double lower, double upper, + double *acceleration, double *velocity) { + double vt = 1e-12 * fmax(fabs(v), 1); + if ((x <= lower + 1e-12*fmax(fabs(lower),1) && v <= vt && *acceleration <= 0) || + (x >= upper - 1e-12*fmax(fabs(upper),1) && v >= -vt && *acceleration >= 0)) { + *acceleration = 0; + *velocity = 0; + } +} + +double native_signal(double t, double start, int stages, int cyclic, const double *data) { + double elapsed = fmax(t-start,0), duration = 0, offset = 0; + for (int i=0;i 0) elapsed = fmod(elapsed,duration); + for (int i=0;i 0 && event <= t) { + double cycle=fmax(0,floor((t-event)/duration)+1); + event += cycle*duration; + if (event <= t) event += duration; + } + if (event > t) result=fmin(result,event); + offset += data[16+i]; + } + return result; +} + +static double ideal_temperature(const NativeMedium *m, double energy, double c) { + double delta = energy - c*m->Tref; + if (fabs(m->slope) <= 1e-15) return m->Tref + delta/c; + double root = sqrt(fmax(c*c + 2*m->slope*delta, 0)); + double a = (-c+root)/m->slope, b = (-c-root)/m->slope; + return m->Tref + (fabs(a)<=fabs(b) ? a : b); +} +int native_medium_init(const NativeMedium *medium, double p, double T, double V, + int legacy_ideal_initial, double *mU) { + if (!(p>0 && T>0 && V>0)) return 0; + if (medium->real_helium && !legacy_ideal_initial) return native_gas_init(p,T,V,mU); + double mass=p*V/(medium->R*T),dt=T-medium->Tref; + double u=medium->real_helium?u_ideal(T):(medium->cp-medium->R)*T+.5*medium->slope*dt*dt; + mU[0]=mass;mU[1]=mass*u; + return isfinite(mU[0]) && isfinite(mU[1]); +} +double native_density(const NativeMedium *m, double p, double T) { + return m->real_helium ? density(p,T) : p/(m->R*T); +} +double native_temperature_ph(const NativeMedium *m, double p, double h) { + return m->real_helium ? temperature_ph(p,h) : ideal_temperature(m,h,m->cp); +} +double native_viscosity(const NativeMedium *m, double T, int diagnostic) { + if (diagnostic && m->real_helium) + return 1e-7*exp(.7501594*log(T)+35.76324/T-2212.129/(T*T)+.9212635); + return m->mu*pow(T/m->muT,1.5)*(m->muT+m->S)/(T+m->S); +} +int native_medium_gas(const NativeMedium *medium, double m, double U, double V, NativeGas *g) { + if (medium->real_helium) return native_gas(m,U,V,g); + if (!(m>0 && V>0)) return 0; + g->u=U/m; g->rho=m/V; + g->T=ideal_temperature(medium,g->u,medium->cp-medium->R); + g->p=g->rho*medium->R*g->T; + double dt=g->T-medium->Tref; + g->h=medium->cp*g->T+.5*medium->slope*dt*dt; + return g->T>0 && isfinite(g->p) && isfinite(g->h); +} +static void medium_valve(const NativeMedium *m, double p, double pd, double T, double *cm, double *vel) { + if (m->real_helium) { valve(p,pd,T,cm,vel); return; } + p=fmax(p,1); pd=fmax(fmin(pd,p),0); T=fmax(T,1); + double cp=m->cp+m->slope*(T-m->Tref); + double g=fmax(1e-9,fmin(1-1e-9,(cp-m->R)/cp)),rho=fmax(native_density(m,p,T),1e-12); + double r=pd/p,critical=pow(2*g/(g+1),1/(1-g)),eff; + if(r<=critical) { eff=critical;*cm=sqrt(2/(1+g)*rho*T/p)*pow(2*g/(g+1),g/(1-g));*vel=sqrt(2/(1+g)*p/rho); } + else { eff=r;*cm=subsonic_cm(r,g,rho,T,p);*vel=sqrt(fmax(2/(1-g)*p/rho*(1-pow(r,1-g)),0)); } + double ref=subsonic_cm(.9999,g,rho,T,p); + if(*cm>0 && ref>0) { double smooth=tanh(fmax(12*fabs(*cm/ref)*log(eff)/log(.9999),0));*cm*=smooth;*vel*=smooth; } +} +int native_medium_orifice(const NativeMedium *m, double p1, double p2, double h1, double h2, + double area, double opening, double *q, double *cm, double *v) { + double p=fmax(p1,p2),pd=fmin(p1,p2),sign=p1>=p2?1:-1; + double T=fmax(native_temperature_ph(m,fmax(p,1),p1>=p2?h1:h2),1); + medium_valve(m,p,pd,T,cm,v); + *q=fabs(p1-p2)<=1e-8?0:sign*area*opening*fmax(p,1)*(*cm)/sqrt(T); + *v*=fabs(opening)<=1e-12?0:sign; + return isfinite(*q) && isfinite(*cm) && isfinite(*v); +} +static double pipe_friction(double re, double rr) { + if(re<=0) return 64000000; + double lam=64/re; + if(re<=89.96829989) return lam; + double smooth=1/pow(-1.8*log10(6.9/re),2),turb=smooth; + if(rr>0) { double r=re*rr,weight=r*r/(r*r+180*180);turb+=weight*(1/pow(-2*log10(rr/3.7),2)-smooth); } + double trans=pow((re-89.96829989)/2741.96700831,8.37293695); + return lam+trans/(1+trans)*(turb-lam); +} +double native_pipe_flow(const NativeMedium *m, double p1, double p2, double T, + double d, double length, double rr, int kind) { + if(fabs(p1-p2)<=1e-8) return 0; + double p=fmax(fmax(p1,p2),1),pd=fmin(p1,p2),sign=p1>p2?1:-1; + T=fmax(T,1); + double area=PI*d*d/4,mu=native_viscosity(m,T,0),den=PI*d*mu; + if(kind==3) { /* PNL0003: Darcy loss between two stored states. */ + double rho=fmax(native_density(m,p,T),1e-12),dp=fabs(p1-p2),lo=0,hi=1e-9; + while(pipe_friction(4*hi/den,rr)*(length/d)*hi*hi/(2*rho*area*area)=1000) return sign*1000; + } + for(int i=0;i<48;i++) { double q=.5*(lo+hi); + if(pipe_friction(4*q/den,rr)*(length/d)*q*q/(2*rho*area*area)0?fmin(penetration/depth,1):1)*damping*velocity; + return signed_force==1?force:fmax(force,0); +} diff --git a/native/include/kernels.h b/native/include/kernels.h new file mode 100644 index 0000000..7977584 --- /dev/null +++ b/native/include/kernels.h @@ -0,0 +1,37 @@ +#ifndef NATIVE_KERNELS_H +#define NATIVE_KERNELS_H +typedef struct { double p, T, rho, u, h; } NativeGas; +typedef struct { + int velocity_index; + double lower, upper; + double lower_restitution, upper_restitution, lower_threshold, upper_threshold; +} NativeStop; +/* Constants are emitted per medium instance by the model compiler. */ +typedef struct { int real_helium; double R, cp, Tref, slope, mu, muT, S; } NativeMedium; +int native_medium_init(const NativeMedium *, double p, double T, double V, int legacy_ideal_initial, double *mU); +double native_density(const NativeMedium *, double p, double T); +double native_temperature_ph(const NativeMedium *, double p, double h); +double native_viscosity(const NativeMedium *, double T, int diagnostic); +int native_medium_gas(const NativeMedium *, double m, double U, double V, NativeGas *); +int native_medium_orifice(const NativeMedium *, double p1, double p2, double h1, double h2, + double cq_area, double opening, double *q, double *cm, double *v); +double native_pipe_flow(const NativeMedium *, double p1, double p2, double T, + double diameter, double length, double roughness, int kind); +void native_pipe_diagnostics(const NativeMedium *, double q, double p, double T, + double diameter, double length, double roughness, + int diagnostic, double *result); +double native_limit_force(double penetration, double velocity, double stiffness, + double damping, double depth, int signed_force); +int native_gas_init(double p, double T, double volume, double *mU); +int native_gas(double m, double U, double volume, NativeGas *gas); +int native_orifice(double p1, double p2, double h1, double h2, + double cq_area, double opening, double *flow, + double *cm, double *velocity); +double native_contact(double penetration, double velocity, double stiffness, + double damping, double pdis, int signed_force); +void native_stop_motion(double x, double v, double lower, double upper, + double *acceleration, double *velocity); +double native_signal(double t, double start, int stages, int cyclic, const double *data); +double native_signal_break(double t, double end, double start, int stages, + int cyclic, const double *data); +#endif diff --git a/native/include/runtime.h b/native/include/runtime.h new file mode 100644 index 0000000..23417e0 --- /dev/null +++ b/native/include/runtime.h @@ -0,0 +1,34 @@ +#ifndef NATIVE_RUNTIME_H +#define NATIVE_RUNTIME_H +#include "model.h" +#include +typedef struct { + double start, stop, sample_step, max_step, rtol, timeout; + int bdf, record_samples; + const char *cancel_path; +} NativeOptions; +typedef struct { + NativeOptions options; + double *times, *states; + size_t count, capacity, sample_index; + double final_time, final_state[NSTATES]; + double wall_start, cpu_start, solve_seconds, solve_cpu_seconds, last_progress; + double max_accepted_step; + unsigned long nfev, accepted, rejected, events, starts, njev, nlu; + int status; /* 0 completed, 1 cancelled, 2 failed */ + const char *message; +} NativeRun; +typedef int (*NativeDense)(void *context, double t, double *state); +double native_wall_time(void); +double native_cpu_time(void); +int native_poll(NativeRun *run, double time); +int native_rhs(NativeRun *run, double t, const double *y, double *dy); +int native_append(NativeRun *run, double t, const double *y); +int native_accept(NativeRun *run, double t, double next, const double *old, + const double *trial, NativeDense dense, void *context, + double *accepted_time, double *accepted_state); +int native_rk45(NativeRun *run); +int native_bdf(NativeRun *run); +int native_solve(NativeRun *run); +void native_run_free(NativeRun *run); +#endif diff --git a/native/runtime/common.c b/native/runtime/common.c new file mode 100644 index 0000000..f5b3dcd --- /dev/null +++ b/native/runtime/common.c @@ -0,0 +1,158 @@ +#include "runtime.h" +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +double native_wall_time(void) { +#ifdef _WIN32 + LARGE_INTEGER value, frequency; + QueryPerformanceCounter(&value); QueryPerformanceFrequency(&frequency); + return (double)value.QuadPart / (double)frequency.QuadPart; +#else + struct timespec value; clock_gettime(CLOCK_MONOTONIC, &value); + return value.tv_sec + value.tv_nsec*1e-9; +#endif +} +double native_cpu_time(void) { +#ifdef _WIN32 + FILETIME creation, exit_time, kernel, user; + GetProcessTimes(GetCurrentProcess(), &creation, &exit_time, &kernel, &user); + ULARGE_INTEGER k, u; k.LowPart=kernel.dwLowDateTime; k.HighPart=kernel.dwHighDateTime; + u.LowPart=user.dwLowDateTime; u.HighPart=user.dwHighDateTime; + return (double)(k.QuadPart+u.QuadPart)*1e-7; +#else + return (double)clock()/CLOCKS_PER_SEC; +#endif +} + +int native_poll(NativeRun *r, double t) { + double now=native_wall_time(); + if (r->status) return 0; + if (r->options.timeout > 0 && now-r->wall_start > r->options.timeout) { + r->status=2; r->message="Native solve exceeded its time limit."; return 0; + } + if (now-r->last_progress >= 0.1) { + r->last_progress=now; + if (r->options.cancel_path) { + FILE *f=fopen(r->options.cancel_path,"rb"); + if (f) { fclose(f); r->status=1; r->message="Simulation cancelled."; return 0; } + } + fprintf(stderr,"{\"phase\":\"integrating\",\"time\":%.17g,\"nfev\":%lu,\"acceptedSteps\":%lu}\n",t,r->nfev,r->accepted); + fflush(stderr); + } + return 1; +} + +int native_rhs(NativeRun *r, double t, const double *y, double *dy) { + double w[NOUTPUTS]; + r->nfev++; + return model_eval(t,y,dy,w); +} + +int native_append(NativeRun *r, double t, const double *y) { + r->final_time=t; memcpy(r->final_state,y,NSTATES*sizeof(double)); + if (!r->options.record_samples) return 1; + if (r->count && r->times[r->count-1] == t) r->count--; + if (r->count == r->capacity) { + size_t cap=r->capacity ? r->capacity*2 : 1024; + if (cap > 2000000 || cap > 268435456u / NSTATES / sizeof(double)) return 0; + double *times=realloc(r->times,cap*sizeof(double)); + if (!times) return 0; + r->times=times; + double *states=realloc(r->states,cap*NSTATES*sizeof(double)); + if (!states) return 0; + r->states=states; r->capacity=cap; + } + r->times[r->count]=t; + memcpy(r->states+r->count*NSTATES,y,NSTATES*sizeof(double)); + r->count++; return 1; +} + +static double locate(int idx, double bound, int lower, double left, double right, + NativeDense dense, void *context) { + double state[NSTATES]; + for (int i=0;i<60;i++) { + double mid=.5*(left+right); + if (!dense(context,mid,state)) return NAN; + if (lower ? state[idx] <= bound : state[idx] >= bound) right=mid; + else left=mid; + } + return right; +} + +int native_accept(NativeRun *r, double t, double next, const double *old, + const double *trial, NativeDense dense, void *context, + double *accepted_time, double *accepted_state) { + double when[2*(NSTOPS+1)], bounds[2*(NSTOPS+1)]; + double restitution[2*(NSTOPS+1)], thresholds[2*(NSTOPS+1)]; + int indices[2*(NSTOPS+1)], count=0; + for (int j=0;j=bound-tol && old[v]>vt0)) at=t; + else if (lower ? (old[x]>bound+tol && trial[x]<=bound) : (old[x]=bound)) + at=locate(x,bound,lower,t,next,dense,context); + else if (lower ? (old[x]<=bound && old[v]>vt0 && trial[v]<-vt1 && trial[x]<=bound) : + (old[x]>=bound && old[v]<-vt0 && trial[v]>vt1 && trial[x]>=bound)) { + double turn=locate(v,0,lower,t,next,dense,context); + at=locate(x,bound,lower,turn,next,dense,context); + } + if (isfinite(at)) { + when[count]=at; bounds[count]=bound; indices[count]=v; + restitution[count]=lower?s.lower_restitution:s.upper_restitution; + thresholds[count]=lower?s.lower_threshold:s.upper_threshold; + count++; + } + } + } + double stop=next; + for (int i=0;ioptions.record_samples) { + double st=r->options.start+r->sample_index*r->options.sample_step; + while (st<=r->options.stop && (count ? stsample_index++; st=r->options.start+r->sample_index*r->options.sample_step; + } + } + if (count) { + if (!dense(context,stop,accepted_state)) return -1; + for (int i=0;ioptions.start+r->sample_index*r->options.sample_step<=stop) r->sample_index++; + r->events++; + } else memcpy(accepted_state,trial,NSTATES*sizeof(double)); + *accepted_time=stop; + r->final_time=stop; memcpy(r->final_state,accepted_state,NSTATES*sizeof(double)); + return count ? 1 : 0; +} + +int native_solve(NativeRun *r) { + double y[NSTATES]; + if (!model_init(y)) { r->status=2; r->message="Native model initialization failed."; return 0; } + r->sample_index=1; + if (!native_append(r,r->options.start,y)) { r->status=2; r->message="Cannot allocate samples."; return 0; } + r->wall_start=native_wall_time(); r->cpu_start=native_cpu_time(); + r->last_progress=r->wall_start-1; + int ok=r->options.bdf ? native_bdf(r) : native_rk45(r); + r->solve_seconds=native_wall_time()-r->wall_start; + r->solve_cpu_seconds=native_cpu_time()-r->cpu_start; + if (!ok && !r->status) { r->status=2; r->message="Native integration failed to advance."; } + if (!native_append(r,r->final_time,r->final_state)) { r->status=2; r->message="Cannot save final state."; } + if (!r->message) r->message=r->status ? "Native integration failed." : "Simulation completed."; + return !r->status; +} + +void native_run_free(NativeRun *r) { free(r->times); free(r->states); } diff --git a/native/runtime/cvode_solver.c b/native/runtime/cvode_solver.c new file mode 100644 index 0000000..28486d8 --- /dev/null +++ b/native/runtime/cvode_solver.c @@ -0,0 +1,97 @@ +/* CVODE owns its default numerical Jacobian and dense linear solver. + * No project Jacobian, sparsity or derivative policy is changed here. + */ +#include "runtime.h" +#include +#include +#include +#include +#include +#include +#include + +typedef struct { void *solver; N_Vector scratch; } CvDense; +static int cv_dense(void *context, double t, double *out) { + CvDense *d=context; + if (CVodeGetDky(d->solver,t,0,d->scratch) < 0) return 0; + memcpy(out,N_VGetArrayPointer(d->scratch),NSTATES*sizeof(double)); + return 1; +} +static int cv_rhs(sunrealtype t, N_Vector y, N_Vector dy, void *context) { + NativeRun *r=context; + if (!native_poll(r,r->final_time)) return -1; + return native_rhs(r,t,N_VGetArrayPointer(y),N_VGetArrayPointer(dy)) ? 0 : 1; +} +static void counters(NativeRun *r, void *solver) { + long int value=0; + CVodeGetNumErrTestFails(solver,&value); r->rejected+=(unsigned long)value; + CVodeGetNumJacEvals(solver,&value); r->njev+=(unsigned long)value; + CVodeGetNumLinSolvSetups(solver,&value); r->nlu+=(unsigned long)value; +} + +int native_bdf(NativeRun *r) { + SUNContext ctx=NULL; + if (SUNContext_Create(SUN_COMM_NULL,&ctx)) return 0; + N_Vector y=N_VNew_Serial(NSTATES,ctx), atol=N_VNew_Serial(NSTATES,ctx), scratch=N_VNew_Serial(NSTATES,ctx); + SUNMatrix matrix=NULL; SUNLinearSolver linear=NULL; void *solver=NULL; + int success=0; + if (!y || !atol || !scratch) goto cleanup; + memcpy(N_VGetArrayPointer(y),r->final_state,NSTATES*sizeof(double)); + memcpy(N_VGetArrayPointer(atol),model_atol,NSTATES*sizeof(double)); + matrix=SUNDenseMatrix(NSTATES,NSTATES,ctx); + if (!matrix) goto cleanup; + linear=SUNLinSol_Dense(y,matrix,ctx); + if (!linear) goto cleanup; + solver=CVodeCreate(CV_BDF,ctx); + if (!solver) goto cleanup; + double t=r->options.start; + if (CVodeInit(solver,cv_rhs,t,y)<0 || CVodeSetUserData(solver,r)<0 || + CVodeSVtolerances(solver,r->options.rtol,atol)<0 || + CVodeSetLinearSolver(solver,linear,matrix)<0 || + CVodeSetMaxStep(solver,r->options.max_step)<0) goto cleanup; + r->starts++; + CvDense dense={solver,scratch}; + while (toptions.stop) { + double boundary=model_next_break(t,r->options.stop); + double end=boundaryoptions.stop?nextafter(boundary,-INFINITY):boundary; + if (CVodeSetStopTime(solver,end)<0) goto cleanup; + while (taccepted>10000000 || r->events>10000) goto cleanup; + double old[NSTATES], accepted[NSTATES], next=t; + memcpy(old,N_VGetArrayPointer(y),sizeof(old)); + int flag=CVode(solver,end,y,&next,CV_ONE_STEP); + if (flag<0 || next<=t) goto cleanup; + r->accepted++; r->max_accepted_step=fmax(r->max_accepted_step,next-t); + int impact=native_accept(r,t,next,old,N_VGetArrayPointer(y),cv_dense,&dense,&t,accepted); + if (impact<0) goto cleanup; + memcpy(N_VGetArrayPointer(y),accepted,sizeof(accepted)); + if (impact) { + counters(r,solver); + if (CVodeReInit(solver,t,y)<0) goto cleanup; + r->starts++; + } + } + t=boundary; r->final_time=t; + memcpy(r->final_state,N_VGetArrayPointer(y),NSTATES*sizeof(double)); + double sample=r->options.start+r->sample_index*r->options.sample_step; + if (r->options.record_samples && sample<=t && sample<=r->options.stop) { + if (!native_append(r,sample,r->final_state)) goto cleanup; + r->sample_index++; + } + if (toptions.stop) { + counters(r,solver); + if (CVodeReInit(solver,t,y)<0) goto cleanup; + r->starts++; + } + } + success=1; +cleanup: + if (solver) { counters(r,solver); CVodeFree(&solver); } + if (linear) SUNLinSolFree(linear); + if (matrix) SUNMatDestroy(matrix); + if (y) N_VDestroy(y); + if (atol) N_VDestroy(atol); + if (scratch) N_VDestroy(scratch); + SUNContext_Free(&ctx); + return success; +} diff --git a/native/runtime/main.c b/native/runtime/main.c new file mode 100644 index 0000000..decaacf --- /dev/null +++ b/native/runtime/main.c @@ -0,0 +1,119 @@ +#include "runtime.h" +#include +#include +#include +#include +#include + +static void json_string(FILE *f, const char *value) { + fputc('"',f); + for (const unsigned char *p=(const unsigned char *)value; *p; p++) { + if (*p=='"' || *p=='\\') { fputc('\\',f); fputc(*p,f); } + else if (*p<32) fprintf(f,"\\u%04x",*p); + else fputc(*p,f); + } + fputc('"',f); +} +static void vector(FILE *f, const double *value, int count) { + fputc('[',f); + for (int i=0;ifinal_time,r->final_state,dy,final); + size_t length=r->count*NOUTPUTS; + if (length>268435456u/sizeof(double)) return 0; + double *values=length?malloc(length*sizeof(double)):NULL; + if (length && !values) return 0; + for (size_t i=0;icount;i++) { + if (!model_eval(r->times[i],r->states+i*NSTATES,dy,values+i*NOUTPUTS)) { + free(values); return 0; + } + } + FILE *f=fopen(path,"wb"); if (!f) { free(values); return 0; } + fprintf(f,"{\"success\":%s,\"status\":",!r->status?"true":"false"); + json_string(f,r->status==0?"completed":r->status==1?"cancelled":"failed"); + fprintf(f,",\"message\":"); json_string(f,r->message); + fprintf(f,",\"backend\":\"native-c\",\"method\":\"%s\",\"solver\":\"%s\",\"sundialsVersion\":\"%s\"," + "\"simulatedUntil\":%.17g,\"solveSeconds\":%.17g,\"solveCpuSeconds\":%.17g," + "\"nfev\":%lu,\"acceptedSteps\":%lu,\"rejectedSteps\":%lu,\"stateTransitions\":%lu," + "\"solverStarts\":%lu,\"njev\":%lu,\"nlu\":%lu,\"maxAcceptedStep\":%.17g,\"series\":{", + r->options.bdf?"BDF":"RK45",r->options.bdf?"CVODE":"Dormand-Prince 5(4)",SUNDIALS_VERSION, + r->final_time,r->solve_seconds,r->solve_cpu_seconds,r->nfev,r->accepted,r->rejected, + r->events,r->starts,r->njev,r->nlu,r->max_accepted_step); + if (r->count) { + fprintf(f,"\"time\":["); + for (size_t i=0;icount;i++) fprintf(f,"%s%.17g",i?",":"",r->times[i]); + fputc(']',f); + for (int j=0;jcount;i++) fprintf(f,"%s%.17g",i?",":"",values[i*NOUTPUTS+j]); + fputc(']',f); + } + } + fprintf(f,"},\"final\":{"); + if (final_ok) for (int j=0;jfinal_state,NSTATES); + fprintf(f,"}\n"); int ok=!ferror(f); if (fclose(f)) ok=0; + free(values); return ok; +} + +int main(int argc, char **argv) { + NativeRun r={0}; + r.options=(NativeOptions){0,10,.02,.001,1e-6,300,0,1,NULL}; + const char *output="result.json"; + for (int i=1;ir.options.start && r.options.sample_step>0 && r.options.max_step>0 && + r.options.rtol>0 && r.options.timeout>0) || + r.options.start+r.options.sample_step==r.options.start || + r.options.start+r.options.max_step==r.options.start || + !isfinite(r.options.stop-r.options.start)) return 64; + if (r.options.record_samples && + ((r.options.stop-r.options.start)/r.options.sample_step>1000000 || + ((r.options.stop-r.options.start)/r.options.sample_step+1024)*(NSTATES+NOUTPUTS)*sizeof(double)>268435456)) return 64; + native_solve(&r); + int saved=write_result(&r,output); + int code=saved?(r.status==2?2:0):3; + native_run_free(&r); return code; +} diff --git a/native/runtime/rk45.c b/native/runtime/rk45.c new file mode 100644 index 0000000..ae682a6 --- /dev/null +++ b/native/runtime/rk45.c @@ -0,0 +1,119 @@ +/* Dormand-Prince 5(4), initial step and quartic dense output. + * Adapted from SciPy's BSD-3-Clause implementation; see THIRD_PARTY_NOTICES.txt. + */ +#include "runtime.h" +#include +#include +static const double C[6]={0,1./5,3./10,4./5,8./9,1}; +static const double A[6][6]={ + {0},{1./5},{3./40,9./40},{44./45,-56./15,32./9}, + {19372./6561,-25360./2187,64448./6561,-212./729}, + {9017./3168,-355./33,46732./5247,49./176,-5103./18656}}; +static const double B[6]={35./384,0,500./1113,125./192,-2187./6784,11./84}; +static const double E[7]={-71./57600,0,71./16695,-71./1920,17253./339200,-22./525,1./40}; +static const double P[7][4]={ + {1,-8048581381./2820520608,8663915743./2820520608,-12715105075./11282082432}, + {0,0,0,0}, + {0,131558114200./32700410799,-68118460800./10900136933,87487479700./32700410799}, + {0,-1754552775./470086768,14199869525./1410260304,-10690763975./1880347072}, + {0,127303824393./49829197408,-318862633887./49829197408,701980252875./199316789632}, + {0,-282668133./205662961,2019193451./616988883,-1453857185./822651844}, + {0,40617522./29380423,-110615467./29380423,69997945./29380423}}; +static double initial_step(NativeRun *s,double t,double end,const double *y,const double *f) { + double scale[NSTATES],trial[NSTATES],f1[NSTATES],d0=0,d1=0,d2=0; + for(int i=0;ioptions.rtol; + d0+=pow(y[i]/scale[i],2);d1+=pow(f[i]/scale[i],2); } + d0=sqrt(d0/NSTATES);d1=sqrt(d1/NSTATES); + double h0=d0<1e-5||d1<1e-5?1e-6:.01*d0/d1;h0=fmin(h0,end-t); + for(int i=0;ioptions.max_step)); +} + +typedef struct { double t, h, y[NSTATES], q[NSTATES][4]; } RkDense; +static int rk_dense(void *context, double t, double *out) { + RkDense *d=context; double x=(t-d->t)/d->h; + double powers[4]={x,x*x,x*x*x,x*x*x*x}; + for (int i=0;iq[i][j]*powers[j]; + out[i]=d->y[i]+d->h*sum; + } + return 1; +} + +int native_rk45(NativeRun *r) { + double y[NSTATES], f[NSTATES], t=r->options.start; + memcpy(y,r->final_state,sizeof(y)); + while (t < r->options.stop) { + double boundary=model_next_break(t,r->options.stop); + double end=boundaryoptions.stop ? nextafter(boundary,-INFINITY) : boundary; + int restart=1; double h_abs=0; + while (taccepted>10000000 || r->events>10000) return 0; + if (restart) { + r->starts++; + if (!native_rhs(r,t,y,f)) return 0; + h_abs=initial_step(r,t,end,y,f); + if (!isfinite(h_abs) || h_abs<=0) return 0; + restart=0; + } + double minimum=10*fabs(nextafter(t,INFINITY)-t); + h_abs=fmax(fmin(h_abs,r->options.max_step),minimum); + double K[7][NSTATES], yn[NSTATES], next, h; + int rejected=0; + for (;;) { + if (h_absoptions.rtol; + error+=pow(h*sum/scale,2); + } + error=sqrt(error/NSTATES); + } else error=INFINITY; + if (error<1) { + double factor=error==0?10:fmin(10,.9*pow(error,-.2)); + if (rejected) factor=fmin(factor,1); + h_abs*=factor; break; + } + h_abs*=fmax(.2,.9*pow(error,-.2)); rejected=1; r->rejected++; + } + RkDense dense={0}; dense.t=t; dense.h=h; memcpy(dense.y,y,sizeof(y)); + for (int i=0;iaccepted++; r->max_accepted_step=fmax(r->max_accepted_step,h); + int impact=native_accept(r,t,next,y,yn,rk_dense,&dense,&t,y); + if (impact<0) return 0; + if (impact) restart=1; + else memcpy(f,K[6],sizeof(f)); + } + t=boundary; r->final_time=t; memcpy(r->final_state,y,sizeof(y)); + double sample=r->options.start+r->sample_index*r->options.sample_step; + if (r->options.record_samples && sample<=t && sample<=r->options.stop) { + if (!native_append(r,sample,y)) return 0; + r->sample_index++; + } + } + return t==r->options.stop; +} diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..5fe64a2 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,2 @@ +-r requirements.txt +numpy>=1.26,<3 diff --git a/requirements.txt b/requirements.txt index b62509e..7b86802 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,5 @@ # should use constraints/python312-linux-x86_64.lock directly; see README.md. fastapi lxml>=5,<7 -numpy>=1.26,<3 pydantic>=2,<3 -scipy>=1.13,<2 uvicorn[standard] diff --git a/tests/baselines/simulation/test_mql_8/manifest.json b/tests/baselines/simulation/test_mql_8/manifest.json index a7ead00..8e0832c 100644 --- a/tests/baselines/simulation/test_mql_8/manifest.json +++ b/tests/baselines/simulation/test_mql_8/manifest.json @@ -3,7 +3,7 @@ "id": "test_mql_8-progressive-v3", "description": "Primary generic-solver regression target for the restored eight-branch inputs aligned to the test_mql AMESim archive, with a short smoke gate before staged extension.", "source": { - "path": "tests/data/test-mql-8.xml", + "path": "tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml", "sha256": "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b", "bytes": 104110, "xmlIsAuthoritative": true, @@ -18,7 +18,7 @@ "resultPointCount": 1002 }, "companionProject": { - "path": "tests/data/test-mql-8.json", + "path": "tests/baselines/simulation/test_mql_8/sources/test-mql-8.json", "sha256": "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18", "bytes": 272592, "executionInput": false @@ -231,7 +231,7 @@ }, "correctness": { "status": "complete", - "maximumScaledResidual": 1e-7, + "maximumScaledResidual": 1e-07, "requireFiniteSeries": true, "requireStrictlyIncreasingTimes": true, "physicalProjectionCategories": [ @@ -242,9 +242,9 @@ "discreteMode" ], "stateRelativeTolerance": 0.0002, - "stateAbsoluteTolerance": 1e-9, + "stateAbsoluteTolerance": 1e-09, "checkpointTimeAbsoluteToleranceSeconds": 1e-12, - "eventTimeAbsoluteToleranceSeconds": 0.00002, + "eventTimeAbsoluteToleranceSeconds": 2e-05, "signalEventTimeAbsoluteToleranceSeconds": 1e-12, "mechanicalTransitionTimesAvailable": true, "physicalBaselineAuthority": "amesim", diff --git a/tests/baselines/simulation/test_mql_8/sources/test-mql-8.json b/tests/baselines/simulation/test_mql_8/sources/test-mql-8.json new file mode 100644 index 0000000..9a419ba --- /dev/null +++ b/tests/baselines/simulation/test_mql_8/sources/test-mql-8.json @@ -0,0 +1,10204 @@ +{ + "projectSchemaVersion": 1, + "name": "test-mql-8", + "nodes": [ + { + "id": "pn_gas_data", + "type": "simulationComponent", + "position": { + "x": -450, + "y": -180 + }, + "data": { + "label": "pn_gas_data", + "componentType": "amesim_helium_medium", + "modelType": "amesim_helium_medium", + "modelVersion": "0.1.0", + "ports": [], + "parameters": { + "gi": 1, + "property_model": 0 + }, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnch023_1", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 140 + }, + "data": { + "label": "pn_general_chamber_副本", + "componentType": "amesim_pnch023", + "modelType": "amesim_pnch023", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol": 0.057, + "kth": 0, + "sth": 0.1, + "extemp": 293.15, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "cvol": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_1", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 89.59999999999997 + }, + "data": { + "label": "pn_orifice_18_副本", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_2", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 190.40000000000003 + }, + "data": { + "label": "pn_orifice_19_副本", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": true + } + }, + { + "id": "amesim_pn3node2_1", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": -15.600000000000051 + }, + "data": { + "label": "pn_node3_8_副本", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pn3node2_2", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": 315.6000000000001 + }, + "data": { + "label": "pn_node3_8_副本_副本", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pn3node2_3", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": 421.2000000000001 + }, + "data": { + "label": "pn_node3_8_副本_副本_2", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnch023_2", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 576.8000000000001 + }, + "data": { + "label": "pn_general_chamber_副本_副本", + "componentType": "amesim_pnch023", + "modelType": "amesim_pnch023", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol": 0.057, + "kth": 0, + "sth": 0.1, + "extemp": 293.15, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "cvol": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_3", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 526.4000000000001 + }, + "data": { + "label": "pn_orifice_18_副本_副本", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_4", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 627.2 + }, + "data": { + "label": "pn_orifice_19_副本_副本", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": true + } + }, + { + "id": "amesim_pn3node2_4", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": 752.4000000000001 + }, + "data": { + "label": "pn_node3_8_副本_副本_副本", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pn3node2_5", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": 858.0000000000002 + }, + "data": { + "label": "pn_node3_8_副本_副本_2_副本", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnch023_3", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 1013.6000000000004 + }, + "data": { + "label": "pn_general_chamber_副本_副本_副本", + "componentType": "amesim_pnch023", + "modelType": "amesim_pnch023", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol": 0.057, + "kth": 0, + "sth": 0.1, + "extemp": 293.15, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "cvol": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_5", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 963.2000000000003 + }, + "data": { + "label": "pn_orifice_18_副本_副本_副本", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_6", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 1064.0000000000002 + }, + "data": { + "label": "pn_orifice_19_副本_副本_副本", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": true + } + }, + { + "id": "amesim_pn3node2_6", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": 1189.1999999999998 + }, + "data": { + "label": "pn_node3_8_副本_副本_副本_副本", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnch023_4", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 1450.3999999999996 + }, + "data": { + "label": "pn_general_chamber_副本_副本_2", + "componentType": "amesim_pnch023", + "modelType": "amesim_pnch023", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol": 0.057, + "kth": 0, + "sth": 0.1, + "extemp": 293.15, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "cvol": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_7", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 1399.9999999999995 + }, + "data": { + "label": "pn_orifice_18_副本_副本_2", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnor001_8", + "type": "simulationComponent", + "position": { + "x": -534, + "y": 1500.7999999999997 + }, + "data": { + "label": "pn_orifice_19_副本_副本_2", + "componentType": "amesim_pnor001", + "modelType": "amesim_pnor001", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.9, + "area": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1 + }, + "parameterUnits": { + "area": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": true + } + }, + { + "id": "amesim_pn3node2_8", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": 1625.9999999999993 + }, + "data": { + "label": "pn_node3_8_副本_副本_2_副本_副本_副本", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_1", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 19.199999999999946 + }, + "data": { + "label": "pneumatic_96_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_2", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 244.80000000000007 + }, + "data": { + "label": "pneumatic_96_副本_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_4", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 456.0000000000001 + }, + "data": { + "label": "pneumatic_96_副本_副本_副本_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_5", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 681.6 + }, + "data": { + "label": "pneumatic_96_副本_副本_副本_2", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_7", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 1118.4 + }, + "data": { + "label": "pneumatic_96_副本_副本_副本_副本_2_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pn3node2_9", + "type": "simulationComponent", + "position": { + "x": -537.1, + "y": 1294.8 + }, + "data": { + "label": "pn_node3_8_副本_副本_2_副本_副本_副本_2", + "componentType": "amesim_pn3node2", + "modelType": "amesim_pn3node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_9", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 892.8000000000003 + }, + "data": { + "label": "pneumatic_96_副本_副本_副本_副本_2_副本_3", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_10", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 1329.6 + }, + "data": { + "label": "pneumatic_96_副本_副本_副本_副本_2_副本_2_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_11", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 1555.1999999999996 + }, + "data": { + "label": "pneumatic_96_副本_副本_副本_副本_2_副本_2_副本_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": 0.0032142857142857142, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 15299999.999999998, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_1", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 36 + }, + "data": { + "label": "pneumatic_76_副本", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_2", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 270 + }, + "data": { + "label": "pneumatic_76_副本_副本", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_3", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 684 + }, + "data": { + "label": "pneumatic_76_副本_副本_2", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_4", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 864 + }, + "data": { + "label": "pneumatic_76_副本_副本_3", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_5", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 1116 + }, + "data": { + "label": "pneumatic_76_副本_副本_4", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_6", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 1296 + }, + "data": { + "label": "pneumatic_76_副本_副本_5", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_7", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 1548 + }, + "data": { + "label": "pneumatic_76_副本_副本_6", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0003_8", + "type": "simulationComponent", + "position": { + "x": -360, + "y": 432 + }, + "data": { + "label": "pneumatic_76_副本_副本_7", + "componentType": "amesim_pnl0003", + "modelType": "amesim_pnl0003", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 0.3, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p1_0": 15299999.999999998, + "T1_0": 293.15, + "p2_0": 15299999.999999998, + "T2_0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p1_0": "bar", + "T1_0": "K", + "p2_0": "bar", + "T2_0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnvo001_1", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 72 + }, + "data": { + "label": "pn_morifice_1_副本", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnvo001_2", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 252 + }, + "data": { + "label": "pn_morifice_1_副本_2", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnvo001_3", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 486 + }, + "data": { + "label": "pn_morifice_1_副本_3", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnvo001_4", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 648 + }, + "data": { + "label": "pn_morifice_1_副本_4", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnvo001_5", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 900 + }, + "data": { + "label": "pn_morifice_1_副本_5", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnvo001_6", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 1098 + }, + "data": { + "label": "pn_morifice_1_副本_6", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnvo001_7", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 1332 + }, + "data": { + "label": "pn_morifice_1_副本_7", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnvo001_8", + "type": "simulationComponent", + "position": { + "x": -198, + "y": 1530 + }, + "data": { + "label": "pn_morifice_1_副本_8", + "componentType": "amesim_pnvo001", + "modelType": "amesim_pnvo001", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cq": 0.45, + "area0": 0.0000785, + "Cv": 0.5, + "Kv": 0.4, + "flowset": 1, + "opening0": 1 + }, + "parameterUnits": { + "area0": "mm2" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_step0_1", + "type": "simulationComponent", + "position": { + "x": -318, + "y": 124 + }, + "data": { + "label": "step_15_副本", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_step0_2", + "type": "simulationComponent", + "position": { + "x": -318, + "y": 340 + }, + "data": { + "label": "step_15_副本_2", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_step0_3", + "type": "simulationComponent", + "position": { + "x": -324, + "y": 558 + }, + "data": { + "label": "step_15_副本_3", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_step0_4", + "type": "simulationComponent", + "position": { + "x": -324, + "y": 756 + }, + "data": { + "label": "step_15_副本_4", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_step0_5", + "type": "simulationComponent", + "position": { + "x": -318, + "y": 970 + }, + "data": { + "label": "step_15_副本_5", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_step0_6", + "type": "simulationComponent", + "position": { + "x": -324, + "y": 1188 + }, + "data": { + "label": "step_15_副本_6", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_step0_7", + "type": "simulationComponent", + "position": { + "x": -318, + "y": 1402 + }, + "data": { + "label": "step_15_副本_7", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_step0_8", + "type": "simulationComponent", + "position": { + "x": -318, + "y": 1636 + }, + "data": { + "label": "step_15_副本_8", + "componentType": "amesim_step0", + "modelType": "amesim_step0", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "initial": 0, + "final": 1, + "time": 0.04 + }, + "parameterUnits": { + "time": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_1", + "type": "simulationComponent", + "position": { + "x": -38, + "y": 91.20000000000002 + }, + "data": { + "label": "pnnode4_19_副本", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_2", + "type": "simulationComponent", + "position": { + "x": -38, + "y": 196.8 + }, + "data": { + "label": "pnnode4_19_副本_2", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_3", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 432 + }, + "data": { + "label": "pnnode4_19_副本_3", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_4", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 537.6000000000001 + }, + "data": { + "label": "pnnode4_19_副本_4", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_5", + "type": "simulationComponent", + "position": { + "x": -38, + "y": 901.2 + }, + "data": { + "label": "pnnode4_19_副本_5", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_6", + "type": "simulationComponent", + "position": { + "x": -38, + "y": 1006.8 + }, + "data": { + "label": "pnnode4_19_副本_6", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_7", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 1296 + }, + "data": { + "label": "pnnode4_19_副本_7", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_p4node2_8", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 1401.5999999999997 + }, + "data": { + "label": "pnnode4_19_副本_8", + "componentType": "amesim_p4node2", + "modelType": "amesim_p4node2", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_1", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 126 + }, + "data": { + "label": "pneumatic_83_副本", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_2", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 306 + }, + "data": { + "label": "pneumatic_83_副本_副本", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_3", + "type": "simulationComponent", + "position": { + "x": -34, + "y": 466.80000000000007 + }, + "data": { + "label": "pneumatic_83_副本_副本_2", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_4", + "type": "simulationComponent", + "position": { + "x": -90, + "y": 684 + }, + "data": { + "label": "pneumatic_83_副本_副本_3", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_5", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 936 + }, + "data": { + "label": "pneumatic_83_副本_副本_4", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_6", + "type": "simulationComponent", + "position": { + "x": -36, + "y": 1134 + }, + "data": { + "label": "pneumatic_83_副本_副本_5", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_7", + "type": "simulationComponent", + "position": { + "x": -34, + "y": 1330.7999999999997 + }, + "data": { + "label": "pneumatic_83_副本_副本_6", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0002_8", + "type": "simulationComponent", + "position": { + "x": 144, + "y": 1422 + }, + "data": { + "label": "pneumatic_83_副本_副本_7", + "componentType": "amesim_pnl0002", + "modelType": "amesim_pnl0002", + "modelVersion": "0.6.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 2, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_13", + "type": "simulationComponent", + "position": { + "x": 198, + "y": 126 + }, + "data": { + "label": "pneumatic_69_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_14", + "type": "simulationComponent", + "position": { + "x": 198, + "y": 198 + }, + "data": { + "label": "pneumatic_69_副本_2", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_15", + "type": "simulationComponent", + "position": { + "x": 180, + "y": 378 + }, + "data": { + "label": "pneumatic_69_副本_3", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_16", + "type": "simulationComponent", + "position": { + "x": 180, + "y": 522 + }, + "data": { + "label": "pneumatic_69_副本_4", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_17", + "type": "simulationComponent", + "position": { + "x": 198, + "y": 846 + }, + "data": { + "label": "pneumatic_69_副本_5", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_18", + "type": "simulationComponent", + "position": { + "x": 198, + "y": 1026 + }, + "data": { + "label": "pneumatic_69_副本_6", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_19", + "type": "simulationComponent", + "position": { + "x": 198, + "y": 1188 + }, + "data": { + "label": "pneumatic_69_副本_7", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_20", + "type": "simulationComponent", + "position": { + "x": 198, + "y": 1296 + }, + "data": { + "label": "pneumatic_69_副本_8", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.02, + "le": 1, + "rr": 0.00225, + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000.00000000001, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "Pa", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_8", + "type": "simulationComponent", + "position": { + "x": 342, + "y": 1368 + }, + "data": { + "label": "pn_c1_10_副本_8", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "Pa", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_1", + "type": "simulationComponent", + "position": { + "x": 360, + "y": 1408 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_2", + "type": "simulationComponent", + "position": { + "x": 360, + "y": 1324 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_9", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 1194 + }, + "data": { + "label": "pn_c1_10_副本_8_副本", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "Pa", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_3", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 1234 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_副本", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_4", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 1150 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2_副本", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_10", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 1032 + }, + "data": { + "label": "pn_c1_10_副本_8_副本_2", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "Pa", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_5", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 1072 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_副本_2", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_6", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 988 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2_副本_2", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_11", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 852 + }, + "data": { + "label": "pn_c1_10_副本_8_副本_3", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "Pa", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_7", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 892 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_副本_3", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_8", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 808 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2_副本_3", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_12", + "type": "simulationComponent", + "position": { + "x": 320, + "y": 528 + }, + "data": { + "label": "pn_c1_10_副本_8_副本_4", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "Pa", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_9", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 568 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_副本_4", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_10", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 484 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2_副本_4", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_13", + "type": "simulationComponent", + "position": { + "x": 320, + "y": 384 + }, + "data": { + "label": "pn_c1_10_副本_8_副本_5", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "Pa", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_11", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 424 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_副本_5", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_12", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 340 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2_副本_5", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_14", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 204 + }, + "data": { + "label": "pn_c1_10_副本_8_副本_6", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "bar", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_13", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 244 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_副本_6", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_14", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 160 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2_副本_6", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnch012_15", + "type": "simulationComponent", + "position": { + "x": 338, + "y": 78 + }, + "data": { + "label": "pn_c1_10_副本_8_副本_7", + "componentType": "amesim_pnch012", + "modelType": "amesim_pnch012", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "cvol0": 0.015, + "kth": 1500, + "sth": 0.7, + "extemp": 293.15, + "p0": 100000.00000000001, + "T0": 293.15, + "vol1": 0, + "vol2": 0, + "vol3": 0, + "vol4": 0, + "dvol1": 0, + "dvol2": 0, + "dvol3": 0, + "dvol4": 0 + }, + "parameterUnits": { + "cvol0": "L", + "kth": "W/(m2*K)", + "sth": "m2", + "extemp": "K", + "p0": "bar", + "T0": "K", + "vol1": "m3", + "vol2": "m3", + "vol3": "m3", + "vol4": "m3", + "dvol1": "m3/s", + "dvol2": "m3/s", + "dvol3": "m3/s", + "dvol4": "m3/s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_15", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 118 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_副本_7", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnpl01_16", + "type": "simulationComponent", + "position": { + "x": 356, + "y": 34 + }, + "data": { + "label": "branch_boundary_pnl00r_branch05_副本_2_副本_7", + "componentType": "amesim_pnpl01", + "modelType": "amesim_pnpl01", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 270, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_1", + "type": "simulationComponent", + "position": { + "x": 558, + "y": -18 + }, + "data": { + "label": "pn_brp2_8_副本", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_2", + "type": "simulationComponent", + "position": { + "x": 558, + "y": 162 + }, + "data": { + "label": "pn_brp2_8_副本_2", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_3", + "type": "simulationComponent", + "position": { + "x": 558, + "y": 324 + }, + "data": { + "label": "pn_brp2_8_副本_3", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_4", + "type": "simulationComponent", + "position": { + "x": 558, + "y": 522 + }, + "data": { + "label": "pn_brp2_8_副本_4", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_5", + "type": "simulationComponent", + "position": { + "x": 558, + "y": 720 + }, + "data": { + "label": "pn_brp2_8_副本_5", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_6", + "type": "simulationComponent", + "position": { + "x": 558, + "y": 918 + }, + "data": { + "label": "pn_brp2_8_副本_6", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_7", + "type": "simulationComponent", + "position": { + "x": 558, + "y": 1098 + }, + "data": { + "label": "pn_brp2_8_副本_7", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_pnrp17_8", + "type": "simulationComponent", + "position": { + "x": 558, + "y": 1278 + }, + "data": { + "label": "pn_brp2_8_副本_8", + "componentType": "amesim_pnrp17", + "modelType": "amesim_pnrp17", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "dp": 0.2, + "dr": 0.001, + "x0": 0 + }, + "parameterUnits": { + "dp": "mm", + "dr": "mm", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_15", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": -43.68675 + }, + "data": { + "label": "zeroforcesource_28_副本", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_f000_16", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": 136.31324999999998 + }, + "data": { + "label": "zeroforcesource_28_副本_2", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_f000_17", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": 298.3132499999999 + }, + "data": { + "label": "zeroforcesource_28_副本_3", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_f000_18", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": 496.31325000000015 + }, + "data": { + "label": "zeroforcesource_28_副本_4", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_f000_19", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": 694.31325 + }, + "data": { + "label": "zeroforcesource_28_副本_5", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_f000_20", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": 892.31325 + }, + "data": { + "label": "zeroforcesource_28_副本_6", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_f000_21", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": 1072.3132499999997 + }, + "data": { + "label": "zeroforcesource_28_副本_7", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_f000_22", + "type": "simulationComponent", + "position": { + "x": 646.5, + "y": 1252.3132499999997 + }, + "data": { + "label": "zeroforcesource_28_副本_8", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_1", + "type": "simulationComponent", + "position": { + "x": 467.7000000000001, + "y": -17.971999999999994 + }, + "data": { + "label": "mass_friction_endstops_11_副本", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_2", + "type": "simulationComponent", + "position": { + "x": 467.70000000000005, + "y": 162.02800000000005 + }, + "data": { + "label": "mass_friction_endstops_11_副本_2", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_3", + "type": "simulationComponent", + "position": { + "x": 467.70000000000016, + "y": 324.02799999999996 + }, + "data": { + "label": "mass_friction_endstops_11_副本_3", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_4", + "type": "simulationComponent", + "position": { + "x": 467.70000000000005, + "y": 522.0280000000001 + }, + "data": { + "label": "mass_friction_endstops_11_副本_4", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_5", + "type": "simulationComponent", + "position": { + "x": 467.70000000000016, + "y": 720.0280000000001 + }, + "data": { + "label": "mass_friction_endstops_11_副本_5", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_6", + "type": "simulationComponent", + "position": { + "x": 467.70000000000005, + "y": 918.028 + }, + "data": { + "label": "mass_friction_endstops_11_副本_6", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_7", + "type": "simulationComponent", + "position": { + "x": 467.70000000000005, + "y": 1098.0280000000002 + }, + "data": { + "label": "mass_friction_endstops_11_副本_7", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_8", + "type": "simulationComponent", + "position": { + "x": 467.70000000000005, + "y": 1278.0279999999998 + }, + "data": { + "label": "mass_friction_endstops_11_副本_8", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 50, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -1, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.8, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 4, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_23", + "type": "simulationComponent", + "position": { + "x": 425.90000000000003, + "y": -1.9719999999999942 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_25", + "type": "simulationComponent", + "position": { + "x": 425.90000000000003, + "y": 178.02800000000008 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本_副本", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_26", + "type": "simulationComponent", + "position": { + "x": 425.9000000000001, + "y": 340.02799999999996 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本_副本_2", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_27", + "type": "simulationComponent", + "position": { + "x": 425.9000000000001, + "y": 538.028 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本_副本_3", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_28", + "type": "simulationComponent", + "position": { + "x": 425.90000000000015, + "y": 736.028 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本_副本_4", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_29", + "type": "simulationComponent", + "position": { + "x": 425.9, + "y": 934.0280000000001 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本_副本_5", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_30", + "type": "simulationComponent", + "position": { + "x": 425.90000000000003, + "y": 1114.0280000000002 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本_副本_6", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_f000_31", + "type": "simulationComponent", + "position": { + "x": 425.90000000000003, + "y": 1294.0279999999998 + }, + "data": { + "label": "zeroforcesource_28_副本_3_副本_副本_7", + "componentType": "amesim_f000", + "modelType": "amesim_f000", + "modelVersion": "0.1.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": {}, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_1", + "type": "simulationComponent", + "position": { + "x": 738, + "y": -18 + }, + "data": { + "label": "elasticendstop_9_副本", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_2", + "type": "simulationComponent", + "position": { + "x": 738, + "y": 162 + }, + "data": { + "label": "elasticendstop_9_副本_2", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_3", + "type": "simulationComponent", + "position": { + "x": 738, + "y": 324 + }, + "data": { + "label": "elasticendstop_9_副本_3", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_4", + "type": "simulationComponent", + "position": { + "x": 738, + "y": 522 + }, + "data": { + "label": "elasticendstop_9_副本_4", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_5", + "type": "simulationComponent", + "position": { + "x": 738, + "y": 720 + }, + "data": { + "label": "elasticendstop_9_副本_5", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_6", + "type": "simulationComponent", + "position": { + "x": 738, + "y": 918 + }, + "data": { + "label": "elasticendstop_9_副本_6", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_7", + "type": "simulationComponent", + "position": { + "x": 738, + "y": 1098 + }, + "data": { + "label": "elasticendstop_9_副本_7", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lstp00a_8", + "type": "simulationComponent", + "position": { + "x": 738, + "y": 1242 + }, + "data": { + "label": "elasticendstop_9_副本_8", + "componentType": "amesim_lstp00a", + "modelType": "amesim_lstp00a", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "na": 10, + "gap0": 0, + "kcont": 100000000000, + "G": 85700000000, + "sdiam": 0.02, + "wdiam": 0.002, + "rcont": 100000000000, + "Pdis": 1.0000000000000001e-7, + "stiffmode": 1, + "discContactOption": 1 + }, + "parameterUnits": { + "gap0": "mm", + "kcont": "N/m", + "G": "Pa", + "sdiam": "mm", + "wdiam": "mm", + "rcont": "N/(m/s)", + "Pdis": "mm" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lmechn1_3", + "type": "simulationComponent", + "position": { + "x": 198, + "y": -234 + }, + "data": { + "label": "amesim_lmechn1_1_副本", + "componentType": "amesim_lmechn1", + "modelType": "amesim_lmechn1", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_6", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_7", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_8", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_9", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_10", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_11", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_12", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_13", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_14", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_15", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_16", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_17", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_18", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_19", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_20", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_21", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": { + "v1": 8, + "sum": 1 + }, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": true + } + }, + { + "id": "amesim_mecmas21_9", + "type": "simulationComponent", + "position": { + "x": 108.20000000000002, + "y": -179 + }, + "data": { + "label": "mass_friction_endstops_19_副本", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 90000, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": -0.72, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 1, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_forc_1", + "type": "simulationComponent", + "position": { + "x": 36.80000000000001, + "y": -179 + }, + "data": { + "label": "forcecon_1_副本", + "componentType": "amesim_forc", + "modelType": "amesim_forc", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "direction": 1 + }, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_ud00_1", + "type": "simulationComponent", + "position": { + "x": -30.39999999999999, + "y": -179 + }, + "data": { + "label": "piecewiselinear_副本", + "componentType": "amesim_ud00", + "modelType": "amesim_ud00", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "tstart": 0, + "start1": 100000000000000000, + "end1": 100000000000000000, + "t1": 0.8, + "start2": 49000, + "end2": 49000, + "t2": 10, + "start3": 0, + "end3": 0, + "t3": 3, + "start4": 0, + "end4": 0, + "t4": 3, + "start5": 0, + "end5": 0, + "t5": 3, + "start6": 0, + "end6": 0, + "t6": 3, + "start7": 0, + "end7": 0, + "t7": 3, + "start8": 0, + "end8": 0, + "t8": 3, + "nstages": 2, + "iscyclic": 1 + }, + "parameterUnits": { + "tstart": "s", + "t1": "s", + "t2": "s", + "t3": "s", + "t4": "s", + "t5": "s", + "t6": "s", + "t7": "s", + "t8": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_lmechn1_4", + "type": "simulationComponent", + "position": { + "x": 1078, + "y": 555 + }, + "data": { + "label": "amesim_lmechn1_2_副本", + "componentType": "amesim_lmechn1", + "modelType": "amesim_lmechn1", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_3", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_4", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_5", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_6", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_7", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_8", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_9", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_10", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_11", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_12", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_13", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_14", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_15", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_16", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_17", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_18", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_19", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_20", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + }, + { + "name": "port_21", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + } + ], + "parameters": { + "v1": 8, + "sum": 1 + }, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_mecmas21_10", + "type": "simulationComponent", + "position": { + "x": 1183.8, + "y": 610 + }, + "data": { + "label": "mass_friction_endstops_18_副本", + "componentType": "amesim_mecmas21", + "modelType": "amesim_mecmas21", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_1", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "mass": 170000, + "fstick": 0, + "fcoul": 0, + "rvisc": 0, + "wind": 0, + "dvel": 0.000001, + "restdvel": 0.000001, + "restcoeff": 0.65, + "astrib": 0.001, + "xmin": 0, + "Kbmin": 1000000000, + "Dbmin": 10000, + "Pdmin": 0.0001, + "xmax": 0.37, + "Kbmax": 1000000000, + "Dbmax": 10000, + "Pdmax": 0.0001, + "theta": 0, + "useFriction": 1, + "stoptype": 1, + "discContactOption": 1, + "strib": 1, + "frictionType": 1, + "v0": 0, + "x0": 0 + }, + "parameterUnits": { + "mass": "kg", + "fstick": "N", + "fcoul": "N", + "rvisc": "N/(m/s)", + "wind": "N/(m/s)^2", + "dvel": "m/s", + "restdvel": "m/s", + "astrib": "m/s", + "xmin": "m", + "Kbmin": "N/m", + "Dbmin": "N/(m/s)", + "Pdmin": "m", + "xmax": "m", + "Kbmax": "N/m", + "Dbmax": "N/(m/s)", + "Pdmax": "m", + "v0": "m/s", + "x0": "m" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": false + } + }, + { + "id": "amesim_forc_2", + "type": "simulationComponent", + "position": { + "x": 1255.1999999999998, + "y": 610 + }, + "data": { + "label": "forcecon_2_副本", + "componentType": "amesim_forc", + "modelType": "amesim_forc", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "res", + "kind": "signal", + "domain": "signal", + "nominalRole": "input", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "mechanical", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "direction": -1 + }, + "parameterUnits": {}, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_ud00_2", + "type": "simulationComponent", + "position": { + "x": 1322.3999999999996, + "y": 610 + }, + "data": { + "label": "piecewiselinear_1_副本", + "componentType": "amesim_ud00", + "modelType": "amesim_ud00", + "modelVersion": "0.2.0", + "ports": [ + { + "name": "out", + "kind": "signal", + "domain": "signal", + "nominalRole": "output", + "side": "right" + } + ], + "parameters": { + "tstart": 0, + "start1": 1000000000000, + "end1": 1000000000000, + "t1": 0.8, + "start2": 0, + "end2": 0, + "t2": 10, + "start3": 0, + "end3": 0, + "t3": 3, + "start4": 0, + "end4": 0, + "t4": 3, + "start5": 0, + "end5": 0, + "t5": 3, + "start6": 0, + "end6": 0, + "t6": 3, + "start7": 0, + "end7": 0, + "t7": 3, + "start8": 0, + "end8": 0, + "t8": 3, + "nstages": 2, + "iscyclic": 1 + }, + "parameterUnits": { + "tstart": "s", + "t1": "s", + "t2": "s", + "t3": "s", + "t4": "s", + "t5": "s", + "t6": "s", + "t7": "s", + "t8": "s" + }, + "parameterScientificNotation": {}, + "rotation": 0, + "mirrored": true + } + }, + { + "id": "amesim_pnl00r_4", + "type": "simulationComponent", + "position": { + "x": -722, + "y": 780 + }, + "data": { + "label": "amesim_pnl00r_1_副本_副本_2", + "componentType": "amesim_pnl00r", + "modelType": "amesim_pnl00r", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": "0.045/14" + }, + "parameterUnits": { + "diam": "mm", + "le": "m" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl00r_5", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 350.4000000000001 + }, + "data": { + "label": "amesim_pnl00r_1_副本_副本_2_副本", + "componentType": "amesim_pnl00r", + "modelType": "amesim_pnl00r", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": "0.045/14" + }, + "parameterUnits": { + "diam": "mm", + "le": "m" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl00r_6", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 787.2000000000002 + }, + "data": { + "label": "amesim_pnl00r_1_副本_副本_2_副本_2", + "componentType": "amesim_pnl00r", + "modelType": "amesim_pnl00r", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": "0.045/14" + }, + "parameterUnits": { + "diam": "mm", + "le": "m" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl00r_7", + "type": "simulationComponent", + "position": { + "x": -542, + "y": 1223.9999999999995 + }, + "data": { + "label": "amesim_pnl00r_1_副本_副本_2_副本_3", + "componentType": "amesim_pnl00r", + "modelType": "amesim_pnl00r", + "modelVersion": "0.3.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 1, + "rr": "0.045/14" + }, + "parameterUnits": { + "diam": "mm", + "le": "m" + }, + "parameterScientificNotation": {}, + "rotation": 90, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_21", + "type": "simulationComponent", + "position": { + "x": -126, + "y": 936 + }, + "data": { + "label": "amesim_pnl0001_21", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 0.3, + "rr": "0.045/14", + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_25", + "type": "simulationComponent", + "position": { + "x": -126, + "y": 1044 + }, + "data": { + "label": "amesim_pnl0001_21_副本", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 0.3, + "rr": "0.045/14", + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_26", + "type": "simulationComponent", + "position": { + "x": -126, + "y": 1350 + }, + "data": { + "label": "amesim_pnl0001_21_副本_2", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 0.3, + "rr": "0.045/14", + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + }, + { + "id": "amesim_pnl0001_27", + "type": "simulationComponent", + "position": { + "x": -120, + "y": 1456 + }, + "data": { + "label": "amesim_pnl0001_21_副本_3", + "componentType": "amesim_pnl0001", + "modelType": "amesim_pnl0001", + "modelVersion": "0.4.0", + "ports": [ + { + "name": "port_1", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "left" + }, + { + "name": "port_2", + "kind": "physical", + "domain": "pneumatic", + "nominalRole": "bidirectional", + "positiveFlowDirection": "intoComponent", + "side": "right" + } + ], + "parameters": { + "gi": 1, + "diam": 0.014, + "le": 0.3, + "rr": "0.045/14", + "k": 1.35, + "kth": 0, + "extemp": 293.15, + "mode": 2, + "p0": 100000, + "T0": 293.15 + }, + "parameterUnits": { + "diam": "mm", + "le": "m", + "kth": "W/(m2*K)", + "extemp": "K", + "p0": "bar", + "T0": "K" + }, + "parameterScientificNotation": {}, + "rotation": 180, + "mirrored": false + } + } + ], + "edges": [ + { + "id": "edge-contact-amesim_pnor001_2-port_2-amesim_pnch023_1-port_1-1786948253132-1", + "source": "amesim_pnor001_2", + "target": "amesim_pnch023_1", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnor001_1-port_1-amesim_pnch023_1-port_2-1786948460485-0", + "source": "amesim_pnor001_1", + "target": "amesim_pnch023_1", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_1-port_2-amesim_pnor001_1-port_2-1786948481782-0", + "source": "amesim_pnl0001_1", + "target": "amesim_pnor001_1", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pn3node2_1-port_1-amesim_pnl0001_1-port_1-1786948483280-0", + "source": "amesim_pn3node2_1", + "target": "amesim_pnl0001_1", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_2-port_2-amesim_pnor001_2-port_1-1786948499851-0", + "source": "amesim_pnl0001_2", + "target": "amesim_pnor001_2", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pn3node2_2-port_3-amesim_pnl0001_2-port_1-1786948516979-0", + "source": "amesim_pn3node2_2", + "target": "amesim_pnl0001_2", + "sourceHandle": "port_3", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_4-port_1-amesim_pn3node2_3-port_1-1786948539164-0", + "source": "amesim_pnl0001_4", + "target": "amesim_pn3node2_3", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnor001_3-port_2-amesim_pnl0001_4-port_2-1786948546851-0", + "source": "amesim_pnor001_3", + "target": "amesim_pnl0001_4", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnch023_2-port_2-amesim_pnor001_3-port_1-1786948548687-0", + "source": "amesim_pnch023_2", + "target": "amesim_pnor001_3", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnor001_4-port_2-amesim_pnch023_2-port_1-1786948572079-0", + "source": "amesim_pnor001_4", + "target": "amesim_pnch023_2", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_5-port_2-amesim_pnor001_4-port_1-1786948580590-0", + "source": "amesim_pnl0001_5", + "target": "amesim_pnor001_4", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pn3node2_4-port_3-amesim_pnl0001_5-port_1-1786948588931-0", + "source": "amesim_pn3node2_4", + "target": "amesim_pnl0001_5", + "sourceHandle": "port_3", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_9-port_1-amesim_pn3node2_5-port_1-1786948716991-0", + "source": "amesim_pnl0001_9", + "target": "amesim_pn3node2_5", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnor001_5-port_2-amesim_pnl0001_9-port_2-1786948732937-0", + "source": "amesim_pnor001_5", + "target": "amesim_pnl0001_9", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnch023_3-port_2-amesim_pnor001_5-port_1-1786948735304-0", + "source": "amesim_pnch023_3", + "target": "amesim_pnor001_5", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnor001_6-port_2-amesim_pnch023_3-port_1-1786948736800-0", + "source": "amesim_pnor001_6", + "target": "amesim_pnch023_3", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnch023_4-port_2-amesim_pnor001_7-port_1-1786948785813-0", + "source": "amesim_pnch023_4", + "target": "amesim_pnor001_7", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnor001_8-port_2-amesim_pnch023_4-port_1-1786948788937-0", + "source": "amesim_pnor001_8", + "target": "amesim_pnch023_4", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_11-port_2-amesim_pnor001_8-port_1-1786948810556-0", + "source": "amesim_pnl0001_11", + "target": "amesim_pnor001_8", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pn3node2_8-port_3-amesim_pnl0001_11-port_1-1786948812613-0", + "source": "amesim_pn3node2_8", + "target": "amesim_pnl0001_11", + "sourceHandle": "port_3", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_10-port_1-amesim_pn3node2_9-port_1-1786948818513-0", + "source": "amesim_pnl0001_10", + "target": "amesim_pn3node2_9", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_10-port_2-amesim_pnor001_7-port_2-1786948818513-1", + "source": "amesim_pnl0001_10", + "target": "amesim_pnor001_7", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pn3node2_1-port_2-amesim_pnl0003_1-port_1-1786948902586", + "source": "amesim_pn3node2_1", + "target": "amesim_pnl0003_1", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pn3node2_2-port_2-amesim_pnl0003_2-port_1-1786948943285", + "source": "amesim_pn3node2_2", + "target": "amesim_pnl0003_2", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pn3node2_3-port_2-amesim_pnl0003_8-port_1-1786948947499", + "source": "amesim_pn3node2_3", + "target": "amesim_pnl0003_8", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pn3node2_4-port_2-amesim_pnl0003_3-port_1-1786948950174", + "source": "amesim_pn3node2_4", + "target": "amesim_pnl0003_3", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pn3node2_5-port_2-amesim_pnl0003_4-port_1-1786948953718", + "source": "amesim_pn3node2_5", + "target": "amesim_pnl0003_4", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pn3node2_6-port_2-amesim_pnl0003_5-port_1-1786948955683", + "source": "amesim_pn3node2_6", + "target": "amesim_pnl0003_5", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pn3node2_9-port_2-amesim_pnl0003_6-port_1-1786948957899", + "source": "amesim_pn3node2_9", + "target": "amesim_pnl0003_6", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pn3node2_8-port_2-amesim_pnl0003_7-port_1-1786948960782", + "source": "amesim_pn3node2_8", + "target": "amesim_pnl0003_7", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_1-port_2-amesim_pnvo001_1-port_3-1786948980355", + "source": "amesim_pnl0003_1", + "target": "amesim_pnvo001_1", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_2-port_2-amesim_pnvo001_2-port_3-1786948998177", + "source": "amesim_pnl0003_2", + "target": "amesim_pnvo001_2", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_8-port_2-amesim_pnvo001_3-port_3-1786948999135", + "source": "amesim_pnl0003_8", + "target": "amesim_pnvo001_3", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_3-port_2-amesim_pnvo001_4-port_3-1786949000506", + "source": "amesim_pnl0003_3", + "target": "amesim_pnvo001_4", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_4-port_2-amesim_pnvo001_5-port_3-1786949001590", + "source": "amesim_pnl0003_4", + "target": "amesim_pnvo001_5", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_5-port_2-amesim_pnvo001_6-port_3-1786949003073", + "source": "amesim_pnl0003_5", + "target": "amesim_pnvo001_6", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_6-port_2-amesim_pnvo001_7-port_3-1786949004303", + "source": "amesim_pnl0003_6", + "target": "amesim_pnvo001_7", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0003_7-port_2-amesim_pnvo001_8-port_3-1786949005669", + "source": "amesim_pnl0003_7", + "target": "amesim_pnvo001_8", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_8-out-amesim_pnvo001_8-res-1786949053526", + "source": "amesim_step0_8", + "target": "amesim_pnvo001_8", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_7-out-amesim_pnvo001_7-res-1786949054636", + "source": "amesim_step0_7", + "target": "amesim_pnvo001_7", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_6-out-amesim_pnvo001_6-res-1786949056049", + "source": "amesim_step0_6", + "target": "amesim_pnvo001_6", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_5-out-amesim_pnvo001_5-res-1786949057321", + "source": "amesim_step0_5", + "target": "amesim_pnvo001_5", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_4-out-amesim_pnvo001_4-res-1786949058632", + "source": "amesim_step0_4", + "target": "amesim_pnvo001_4", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_3-out-amesim_pnvo001_3-res-1786949059791", + "source": "amesim_step0_3", + "target": "amesim_pnvo001_3", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_2-out-amesim_pnvo001_2-res-1786949060983", + "source": "amesim_step0_2", + "target": "amesim_pnvo001_2", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_step0_1-out-amesim_pnvo001_1-res-1786949061988", + "source": "amesim_step0_1", + "target": "amesim_pnvo001_1", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnvo001_1-port_2-amesim_p4node2_1-port_3-1786949112480", + "source": "amesim_pnvo001_1", + "target": "amesim_p4node2_1", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnvo001_2-port_2-amesim_p4node2_2-port_2-1786949129074", + "source": "amesim_pnvo001_2", + "target": "amesim_p4node2_2", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnvo001_3-port_2-amesim_p4node2_3-port_2-1786949131664", + "source": "amesim_pnvo001_3", + "target": "amesim_p4node2_3", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnvo001_4-port_2-amesim_p4node2_4-port_2-1786949134056", + "source": "amesim_pnvo001_4", + "target": "amesim_p4node2_4", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_p4node2_2-port_4-amesim_pnl0002_2-port_1-1786949195556", + "source": "amesim_p4node2_2", + "target": "amesim_pnl0002_2", + "sourceHandle": "port_4", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0002_2-port_2-amesim_p4node2_3-port_1-1786949196897", + "source": "amesim_pnl0002_2", + "target": "amesim_p4node2_3", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_pnl0002_3-port_1-amesim_p4node2_3-port_4-1786949203907-0", + "source": "amesim_pnl0002_3", + "target": "amesim_p4node2_3", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_p4node2_4-port_1-amesim_pnl0002_3-port_2-1786949205596-0", + "source": "amesim_p4node2_4", + "target": "amesim_pnl0002_3", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_p4node2_4-port_4-amesim_pnl0002_4-port_1-1786949231675", + "source": "amesim_p4node2_4", + "target": "amesim_pnl0002_4", + "sourceHandle": "port_4", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0002_4-port_2-amesim_p4node2_5-port_1-1786949232817", + "source": "amesim_pnl0002_4", + "target": "amesim_p4node2_5", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_p4node2_5-port_4-amesim_pnl0002_5-port_1-1786949246412-0", + "source": "amesim_p4node2_5", + "target": "amesim_pnl0002_5", + "sourceHandle": "port_4", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_p4node2_6-port_1-amesim_pnl0002_5-port_2-1786949247647-0", + "source": "amesim_p4node2_6", + "target": "amesim_pnl0002_5", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0002_6-port_1-amesim_p4node2_6-port_4-1786949249869", + "source": "amesim_pnl0002_6", + "target": "amesim_p4node2_6", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0002_6-port_2-amesim_p4node2_7-port_1-1786949250871", + "source": "amesim_pnl0002_6", + "target": "amesim_p4node2_7", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_pnl0002_7-port_1-amesim_p4node2_7-port_4-1786949265270-0", + "source": "amesim_pnl0002_7", + "target": "amesim_p4node2_7", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_p4node2_8-port_1-amesim_pnl0002_7-port_2-1786949266454-0", + "source": "amesim_p4node2_8", + "target": "amesim_pnl0002_7", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_p4node2_2-port_3-amesim_pnl0001_14-port_2-1786949362056", + "source": "amesim_p4node2_2", + "target": "amesim_pnl0001_14", + "sourceHandle": "port_3", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_p4node2_3-port_3-amesim_pnl0001_15-port_2-1786949364233", + "source": "amesim_p4node2_3", + "target": "amesim_pnl0001_15", + "sourceHandle": "port_3", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_p4node2_4-port_3-amesim_pnl0001_16-port_2-1786949365198", + "source": "amesim_p4node2_4", + "target": "amesim_pnl0001_16", + "sourceHandle": "port_3", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_p4node2_5-port_3-amesim_pnl0001_17-port_2-1786949374542", + "source": "amesim_p4node2_5", + "target": "amesim_pnl0001_17", + "sourceHandle": "port_3", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_p4node2_6-port_3-amesim_pnl0001_18-port_2-1786949377180", + "source": "amesim_p4node2_6", + "target": "amesim_pnl0001_18", + "sourceHandle": "port_3", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_p4node2_7-port_3-amesim_pnl0001_19-port_2-1786949380063", + "source": "amesim_p4node2_7", + "target": "amesim_pnl0001_19", + "sourceHandle": "port_3", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_p4node2_8-port_3-amesim_pnl0001_20-port_2-1786949381164", + "source": "amesim_p4node2_8", + "target": "amesim_pnl0001_20", + "sourceHandle": "port_3", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnl0001_20-port_1-amesim_pnch012_8-port_1-1786949456938", + "source": "amesim_pnl0001_20", + "target": "amesim_pnch012_8", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_pnpl01_1-port_1-amesim_pnch012_8-port_4-1786949466736-0", + "source": "amesim_pnpl01_1", + "target": "amesim_pnch012_8", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnpl01_2-port_1-amesim_pnch012_8-port_3-1786949475922-0", + "source": "amesim_pnpl01_2", + "target": "amesim_pnch012_8", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949488153-0", + "source": "amesim_pnpl01_3", + "target": "amesim_pnch012_9", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949488153-1", + "source": "amesim_pnpl01_4", + "target": "amesim_pnch012_9", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0001_19-port_1-amesim_pnch012_9-port_1-1786949490333", + "source": "amesim_pnl0001_19", + "target": "amesim_pnch012_9", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-copy-1786949494191-0", + "source": "amesim_pnpl01_5", + "target": "amesim_pnch012_10", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949494191-1", + "source": "amesim_pnpl01_6", + "target": "amesim_pnch012_10", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0001_18-port_1-amesim_pnch012_10-port_1-1786949496731", + "source": "amesim_pnl0001_18", + "target": "amesim_pnch012_10", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-copy-1786949500015-0", + "source": "amesim_pnpl01_7", + "target": "amesim_pnch012_11", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949500015-1", + "source": "amesim_pnpl01_8", + "target": "amesim_pnch012_11", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0001_17-port_1-amesim_pnch012_11-port_1-1786949501661", + "source": "amesim_pnl0001_17", + "target": "amesim_pnch012_11", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-copy-1786949504776-0", + "source": "amesim_pnpl01_9", + "target": "amesim_pnch012_12", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949504776-1", + "source": "amesim_pnpl01_10", + "target": "amesim_pnch012_12", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0001_16-port_1-amesim_pnch012_12-port_1-1786949506502", + "source": "amesim_pnl0001_16", + "target": "amesim_pnch012_12", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-copy-1786949508480-0", + "source": "amesim_pnpl01_11", + "target": "amesim_pnch012_13", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949508480-1", + "source": "amesim_pnpl01_12", + "target": "amesim_pnch012_13", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0001_15-port_1-amesim_pnch012_13-port_1-1786949510820", + "source": "amesim_pnl0001_15", + "target": "amesim_pnch012_13", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-copy-1786949515328-0", + "source": "amesim_pnpl01_13", + "target": "amesim_pnch012_14", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949515328-1", + "source": "amesim_pnpl01_14", + "target": "amesim_pnch012_14", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0001_14-port_1-amesim_pnch012_14-port_1-1786949517114", + "source": "amesim_pnl0001_14", + "target": "amesim_pnch012_14", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-copy-1786949519920-0", + "source": "amesim_pnpl01_15", + "target": "amesim_pnch012_15", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-copy-1786949519920-1", + "source": "amesim_pnpl01_16", + "target": "amesim_pnch012_15", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnl0001_13-port_1-amesim_pnch012_15-port_1-1786949522082", + "source": "amesim_pnl0001_13", + "target": "amesim_pnch012_15", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_15-port_2-amesim_pnrp17_1-port_1-1786949561970", + "source": "amesim_pnch012_15", + "target": "amesim_pnrp17_1", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_14-port_2-amesim_pnrp17_2-port_1-1786949570722", + "source": "amesim_pnch012_14", + "target": "amesim_pnrp17_2", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_13-port_2-amesim_pnrp17_3-port_1-1786949575918", + "source": "amesim_pnch012_13", + "target": "amesim_pnrp17_3", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_12-port_2-amesim_pnrp17_4-port_1-1786949579000", + "source": "amesim_pnch012_12", + "target": "amesim_pnrp17_4", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_11-port_2-amesim_pnrp17_5-port_1-1786949583304", + "source": "amesim_pnch012_11", + "target": "amesim_pnrp17_5", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_10-port_2-amesim_pnrp17_6-port_1-1786949586596", + "source": "amesim_pnch012_10", + "target": "amesim_pnrp17_6", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_9-port_2-amesim_pnrp17_7-port_1-1786949589317", + "source": "amesim_pnch012_9", + "target": "amesim_pnrp17_7", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnch012_8-port_2-amesim_pnrp17_8-port_1-1786949590677", + "source": "amesim_pnch012_8", + "target": "amesim_pnrp17_8", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_f000_15-port_1-amesim_pnrp17_1-port_4-1786949604845-0", + "source": "amesim_f000_15", + "target": "amesim_pnrp17_1", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_16-port_1-amesim_pnrp17_2-port_4-1786949607587-0", + "source": "amesim_f000_16", + "target": "amesim_pnrp17_2", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_17-port_1-amesim_pnrp17_3-port_4-1786949609264-0", + "source": "amesim_f000_17", + "target": "amesim_pnrp17_3", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_18-port_1-amesim_pnrp17_4-port_4-1786949612717-0", + "source": "amesim_f000_18", + "target": "amesim_pnrp17_4", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_19-port_1-amesim_pnrp17_5-port_4-1786949616563-0", + "source": "amesim_f000_19", + "target": "amesim_pnrp17_5", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_20-port_1-amesim_pnrp17_6-port_4-1786949618193-0", + "source": "amesim_f000_20", + "target": "amesim_pnrp17_6", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_21-port_1-amesim_pnrp17_7-port_4-1786949619936-0", + "source": "amesim_f000_21", + "target": "amesim_pnrp17_7", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_22-port_1-amesim_pnrp17_8-port_4-1786949621461-0", + "source": "amesim_f000_22", + "target": "amesim_pnrp17_8", + "sourceHandle": "port_1", + "targetHandle": "port_4", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_2-port_1-amesim_pnrp17_2-port_2-1786949641925-0", + "source": "amesim_mecmas21_2", + "target": "amesim_pnrp17_2", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_3-port_1-amesim_pnrp17_3-port_2-1786949643700-0", + "source": "amesim_mecmas21_3", + "target": "amesim_pnrp17_3", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_4-port_1-amesim_pnrp17_4-port_2-1786949645226-0", + "source": "amesim_mecmas21_4", + "target": "amesim_pnrp17_4", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_5-port_1-amesim_pnrp17_5-port_2-1786949646692-0", + "source": "amesim_mecmas21_5", + "target": "amesim_pnrp17_5", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_6-port_1-amesim_pnrp17_6-port_2-1786949648283-0", + "source": "amesim_mecmas21_6", + "target": "amesim_pnrp17_6", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_7-port_1-amesim_pnrp17_7-port_2-1786949649552-0", + "source": "amesim_mecmas21_7", + "target": "amesim_pnrp17_7", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_8-port_1-amesim_pnrp17_8-port_2-1786949650868-0", + "source": "amesim_mecmas21_8", + "target": "amesim_pnrp17_8", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_25-port_1-amesim_mecmas21_2-port_2-1786949669623-0", + "source": "amesim_f000_25", + "target": "amesim_mecmas21_2", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_26-port_1-amesim_mecmas21_3-port_2-1786949671407-0", + "source": "amesim_f000_26", + "target": "amesim_mecmas21_3", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_27-port_1-amesim_mecmas21_4-port_2-1786949674147-0", + "source": "amesim_f000_27", + "target": "amesim_mecmas21_4", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_28-port_1-amesim_mecmas21_5-port_2-1786949676162-0", + "source": "amesim_f000_28", + "target": "amesim_mecmas21_5", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_29-port_1-amesim_mecmas21_6-port_2-1786949678049-0", + "source": "amesim_f000_29", + "target": "amesim_mecmas21_6", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_30-port_1-amesim_mecmas21_7-port_2-1786949680546-0", + "source": "amesim_f000_30", + "target": "amesim_mecmas21_7", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_f000_31-port_1-amesim_mecmas21_8-port_2-1786949684808-0", + "source": "amesim_f000_31", + "target": "amesim_mecmas21_8", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pnrp17_1-port_5-amesim_lstp00a_1-port_1-1786949716336", + "source": "amesim_pnrp17_1", + "target": "amesim_lstp00a_1", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnrp17_2-port_5-amesim_lstp00a_2-port_1-1786949718088", + "source": "amesim_pnrp17_2", + "target": "amesim_lstp00a_2", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnrp17_3-port_5-amesim_lstp00a_3-port_1-1786949719667", + "source": "amesim_pnrp17_3", + "target": "amesim_lstp00a_3", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnrp17_4-port_5-amesim_lstp00a_4-port_1-1786949721116", + "source": "amesim_pnrp17_4", + "target": "amesim_lstp00a_4", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnrp17_5-port_5-amesim_lstp00a_5-port_1-1786949722354", + "source": "amesim_pnrp17_5", + "target": "amesim_lstp00a_5", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnrp17_6-port_5-amesim_lstp00a_6-port_1-1786949723572", + "source": "amesim_pnrp17_6", + "target": "amesim_lstp00a_6", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnrp17_7-port_5-amesim_lstp00a_7-port_1-1786949724805", + "source": "amesim_pnrp17_7", + "target": "amesim_lstp00a_7", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnrp17_8-port_5-amesim_lstp00a_8-port_1-1786949726097", + "source": "amesim_pnrp17_8", + "target": "amesim_lstp00a_8", + "sourceHandle": "port_5", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_mecmas21_1-port_2-amesim_f000_23-port_1-1786949817764-0", + "source": "amesim_mecmas21_1", + "target": "amesim_f000_23", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_1-port_1-amesim_pnrp17_1-port_2-1786949817764-1", + "source": "amesim_mecmas21_1", + "target": "amesim_pnrp17_1", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_mecmas21_9-port_1-amesim_lmechn1_3-port_9-1786949831063-0", + "source": "amesim_mecmas21_9", + "target": "amesim_lmechn1_3", + "sourceHandle": "port_1", + "targetHandle": "port_9", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_forc_1-port_2-amesim_mecmas21_9-port_2-1786949832141-0", + "source": "amesim_forc_1", + "target": "amesim_mecmas21_9", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_ud00_1-out-amesim_forc_1-res-1786949833688-0", + "source": "amesim_ud00_1", + "target": "amesim_forc_1", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_lmechn1_3-port_1-amesim_pnrp17_1-port_3-1786949849269", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_1", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lmechn1_3-port_2-amesim_pnrp17_2-port_3-1786949850839", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_2", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lmechn1_3-port_3-amesim_pnrp17_3-port_3-1786949852383", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_3", + "sourceHandle": "port_3", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lmechn1_3-port_4-amesim_pnrp17_4-port_3-1786949854874", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_4", + "sourceHandle": "port_4", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lmechn1_3-port_5-amesim_pnrp17_5-port_3-1786949858405", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_5", + "sourceHandle": "port_5", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lmechn1_3-port_6-amesim_pnrp17_6-port_3-1786949862365", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_6", + "sourceHandle": "port_6", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lmechn1_3-port_7-amesim_pnrp17_7-port_3-1786949896879", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_7", + "sourceHandle": "port_7", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lmechn1_3-port_8-amesim_pnrp17_8-port_3-1786949898922", + "source": "amesim_lmechn1_3", + "target": "amesim_pnrp17_8", + "sourceHandle": "port_8", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_mecmas21_10-port_2-amesim_lmechn1_4-port_9-1786949926181-0", + "source": "amesim_mecmas21_10", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_9", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_forc_2-port_2-amesim_mecmas21_10-port_1-1786949929831-0", + "source": "amesim_forc_2", + "target": "amesim_mecmas21_10", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_ud00_2-out-amesim_forc_2-res-1786949931612-0", + "source": "amesim_ud00_2", + "target": "amesim_forc_2", + "sourceHandle": "out", + "targetHandle": "res", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_lstp00a_1-port_2-amesim_lmechn1_4-port_8-1786949940789", + "source": "amesim_lstp00a_1", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_8", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lstp00a_2-port_2-amesim_lmechn1_4-port_7-1786949942329", + "source": "amesim_lstp00a_2", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_7", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lstp00a_3-port_2-amesim_lmechn1_4-port_6-1786949943732", + "source": "amesim_lstp00a_3", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_6", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lstp00a_4-port_2-amesim_lmechn1_4-port_5-1786949945286", + "source": "amesim_lstp00a_4", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_5", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lstp00a_5-port_2-amesim_lmechn1_4-port_4-1786949947132", + "source": "amesim_lstp00a_5", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_4", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lstp00a_6-port_2-amesim_lmechn1_4-port_3-1786949948395", + "source": "amesim_lstp00a_6", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lstp00a_7-port_2-amesim_lmechn1_4-port_2-1786949951554", + "source": "amesim_lstp00a_7", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_lstp00a_8-port_2-amesim_lmechn1_4-port_1-1786949953507", + "source": "amesim_lstp00a_8", + "target": "amesim_lmechn1_4", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-contact-amesim_pnl0001_7-port_2-amesim_pnor001_6-port_1-1786952236164-0", + "source": "amesim_pnl0001_7", + "target": "amesim_pnor001_6", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0001_7-port_1-amesim_pn3node2_6-port_3-1786952236164-1", + "source": "amesim_pnl0001_7", + "target": "amesim_pn3node2_6", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl00r_5-port_1-amesim_pn3node2_2-port_1-1786953128004-0", + "source": "amesim_pnl00r_5", + "target": "amesim_pn3node2_2", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl00r_5-port_2-amesim_pn3node2_3-port_3-1786953128004-1", + "source": "amesim_pnl00r_5", + "target": "amesim_pn3node2_3", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl00r_6-port_1-amesim_pn3node2_4-port_1-1786953132257-0", + "source": "amesim_pnl00r_6", + "target": "amesim_pn3node2_4", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl00r_6-port_2-amesim_pn3node2_5-port_3-1786953132257-1", + "source": "amesim_pnl00r_6", + "target": "amesim_pn3node2_5", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl00r_7-port_1-amesim_pn3node2_6-port_1-1786953135663-0", + "source": "amesim_pnl00r_7", + "target": "amesim_pn3node2_6", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl00r_7-port_2-amesim_pn3node2_9-port_3-1786953135663-1", + "source": "amesim_pnl00r_7", + "target": "amesim_pn3node2_9", + "sourceHandle": "port_2", + "targetHandle": "port_3", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_pn3node2_8-port_1-amesim_pnl00r_4-port_2-1786972796653", + "source": "amesim_pn3node2_8", + "target": "amesim_pnl00r_4", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -486, + "y": 1756.4910508265027 + }, + { + "x": -666, + "y": 1756.4910508265027 + } + ] + } + }, + { + "id": "edge-amesim_pnl00r_4-port_1-amesim_pn3node2_1-port_3-1786972805226", + "source": "amesim_pnl00r_4", + "target": "amesim_pn3node2_1", + "sourceHandle": "port_1", + "targetHandle": "port_3", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -666, + "y": -34.345910698469694 + }, + { + "x": -486, + "y": -34.345910698469694 + } + ] + } + }, + { + "id": "edge-contact-amesim_pnl0002_1-port_2-amesim_p4node2_2-port_1-1786972813085-0", + "source": "amesim_pnl0002_1", + "target": "amesim_p4node2_2", + "sourceHandle": "port_2", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-contact-amesim_pnl0002_1-port_1-amesim_p4node2_1-port_1-1786972813085-1", + "source": "amesim_pnl0002_1", + "target": "amesim_p4node2_1", + "sourceHandle": "port_1", + "targetHandle": "port_1", + "data": { + "isContactEdge": true + } + }, + { + "id": "edge-amesim_p4node2_1-port_4-amesim_pnl0002_8-port_2-1786972824427", + "source": "amesim_p4node2_1", + "target": "amesim_pnl0002_8", + "sourceHandle": "port_4", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": 19.87945415380198, + "y": 52.17994257325029 + }, + { + "x": 199.87521088533356, + "y": 52.17994257325029 + } + ] + } + }, + { + "id": "edge-amesim_p4node2_1-port_2-amesim_pnl0001_13-port_2-1786972847258", + "source": "amesim_p4node2_1", + "target": "amesim_pnl0001_13", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": 80.32926682952949, + "y": 139.08364630171945 + }, + { + "x": 80.32926682952949, + "y": 181.8752425865067 + } + ] + } + }, + { + "id": "edge-amesim_p4node2_8-port_4-amesim_pnl0002_8-port_1-1786972860850", + "source": "amesim_p4node2_8", + "target": "amesim_pnl0002_8", + "sourceHandle": "port_4", + "targetHandle": "port_1", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": 21.87942245262885, + "y": 1602.5991575147043 + }, + { + "x": 199.87521088533356, + "y": 1602.5991575147043 + } + ] + } + }, + { + "id": "edge-amesim_pnl0001_21-port_1-amesim_p4node2_5-port_2-1787035169994", + "source": "amesim_pnl0001_21", + "target": "amesim_p4node2_5", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnvo001_5-port_2-amesim_pnl0001_21-port_2-1787035172093", + "source": "amesim_pnvo001_5", + "target": "amesim_pnl0001_21", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false + } + }, + { + "id": "edge-amesim_pnvo001_8-port_2-amesim_pnl0001_27-port_2-1787035308044", + "source": "amesim_pnvo001_8", + "target": "amesim_pnl0001_27", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -99.4, + "y": 1578 + }, + { + "x": -99.4, + "y": 1512 + } + ] + } + }, + { + "id": "edge-amesim_pnl0001_27-port_1-amesim_p4node2_8-port_2-1787035309121", + "source": "amesim_pnl0001_27", + "target": "amesim_p4node2_8", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -8.800000000000004, + "y": 1512 + }, + { + "x": -8.800000000000004, + "y": 1449.5999999999997 + } + ] + } + }, + { + "id": "edge-amesim_pnvo001_7-port_2-amesim_pnl0001_26-port_2-1787035310228", + "source": "amesim_pnvo001_7", + "target": "amesim_pnl0001_26", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -99.4, + "y": 1380 + }, + { + "x": -99.4, + "y": 1350 + } + ] + } + }, + { + "id": "edge-amesim_pnl0001_26-port_1-amesim_p4node2_7-port_2-1787035311875", + "source": "amesim_pnl0001_26", + "target": "amesim_p4node2_7", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -8.800000000000004, + "y": 1350 + }, + { + "x": -8.800000000000004, + "y": 1344 + } + ] + } + }, + { + "id": "edge-amesim_pnvo001_6-port_2-amesim_pnl0001_25-port_2-1787035319037", + "source": "amesim_pnvo001_6", + "target": "amesim_pnl0001_25", + "sourceHandle": "port_2", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -99.4, + "y": 1146 + }, + { + "x": -99.4, + "y": 1116 + } + ] + } + }, + { + "id": "edge-amesim_pnl0001_25-port_1-amesim_p4node2_6-port_2-1787035320205", + "source": "amesim_pnl0001_25", + "target": "amesim_p4node2_6", + "sourceHandle": "port_1", + "targetHandle": "port_2", + "data": { + "isContactEdge": false, + "routePoints": [ + { + "x": -9.800000000000004, + "y": 1116 + }, + { + "x": -9.800000000000004, + "y": 1054.8 + } + ] + } + } + ], + "simulation": { + "t_start": 0, + "t_stop": 10, + "step": 0.01, + "max_step": 0.001, + "method": "BDF" + } +} diff --git a/tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml b/tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml new file mode 100644 index 0000000..85fcd80 --- /dev/null +++ b/tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml @@ -0,0 +1,2078 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/native-python-reference.json b/tests/data/native-python-reference.json new file mode 100644 index 0000000..6fdb319 --- /dev/null +++ b/tests/data/native-python-reference.json @@ -0,0 +1 @@ +{"provenance":"Frozen from the existing Python implementation before its removal on 2026-09-10. Expected values are not generated by the C implementation.","rtol":2e-08,"atol":2e-07,"cases":[{"components":[{"name":"amesim_pnch023_1","type":"amesim_pnch023","parameters":{"gi":1.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":15300000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnvo001_1","type":"amesim_pnvo001","parameters":{"gi":1.0,"cq":0.45,"area0":7.85e-05,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_step0_1","type":"amesim_step0","parameters":{"initial":0.0,"final":1.0,"time":0.04},"overrides":{},"medium":null},{"name":"amesim_pnch012_1","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_2","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_ud00_1","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1e+17,"end1":1e+17,"t1":0.8,"start2":49000.0,"end2":49000.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"iscyclic":true,"starts":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2},"medium":null},{"name":"amesim_forc_1","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"amesim_pnrp17_1","type":"amesim_pnrp17","parameters":{"gi":1.0,"dp":0.2,"dr":0.001,"x0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnch012_2","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_7","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_9","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_mecmas21_2","type":"amesim_mecmas21","parameters":{"mass":50.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.8,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"amesim_f000_1","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_f000_2","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_lstp00a_1","type":"amesim_lstp00a","parameters":{"na":10.0,"gap0":0.0,"kcont":100000000000.0,"G":85700000000.0,"sdiam":0.02,"wdiam":0.002,"rcont":100000000000.0,"Pdis":1e-07,"stiffmode":1.0,"discContactOption":1.0},"overrides":{"stiffmode":2,"discContactOption":2},"medium":null},{"name":"amesim_forc_2","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"amesim_ud00_2","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1000000000000.0,"end1":1000000000000.0,"t1":0.8,"start2":0.0,"end2":0.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"iscyclic":true,"starts":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2},"medium":null},{"name":"amesim_mecmas21_5","type":"amesim_mecmas21","parameters":{"mass":170000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":0.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.37,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"amesim_mecmas21_7","type":"amesim_mecmas21","parameters":{"mass":90000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-0.72,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null}],"connections":[[["amesim_pnch023_1","port_2"],["amesim_pnvo001_1","port_2"]],[["amesim_pnch012_1","port_1"],["amesim_pnvo001_1","port_3"]],[["amesim_pnch012_1","port_2"],["amesim_pnpl01_2","port_1"]],[["amesim_pnch012_1","port_3"],["amesim_pnpl01_3","port_1"]],[["amesim_pnch012_1","port_4"],["amesim_pnpl01_4","port_1"]],[["amesim_step0_1","out"],["amesim_pnvo001_1","res"]],[["amesim_ud00_1","out"],["amesim_forc_1","res"]],[["amesim_pnch023_1","port_1"],["amesim_pnpl01_6","port_1"]],[["amesim_pnch012_2","port_1"],["amesim_pnpl01_7","port_1"]],[["amesim_pnch012_2","port_4"],["amesim_pnpl01_8","port_1"]],[["amesim_pnch012_2","port_2"],["amesim_pnpl01_9","port_1"]],[["amesim_mecmas21_2","port_1"],["amesim_pnrp17_1","port_2"]],[["amesim_lstp00a_1","port_1"],["amesim_pnrp17_1","port_5"]],[["amesim_ud00_2","out"],["amesim_forc_2","res"]],[["amesim_pnch012_2","port_3"],["amesim_pnrp17_1","port_1"]],[["amesim_forc_1","port_2"],["amesim_mecmas21_7","port_2"]],[["amesim_mecmas21_7","port_1"],["amesim_pnrp17_1","port_3"]],[["amesim_forc_2","port_2"],["amesim_mecmas21_5","port_1"]],[["amesim_lstp00a_1","port_2"],["amesim_mecmas21_5","port_2"]],[["amesim_f000_2","port_1"],["amesim_pnrp17_1","port_4"]],[["amesim_f000_1","port_1"],["amesim_mecmas21_2","port_2"]]],"stateKeys":["amesim_pnch023_1.m","amesim_pnch023_1.U","amesim_pnch012_1.m","amesim_pnch012_1.U","amesim_pnch012_2.m","amesim_pnch012_2.U","amesim_mecmas21_2.v","amesim_mecmas21_2.x","amesim_mecmas21_5.v","amesim_mecmas21_5.x","amesim_mecmas21_7.v","amesim_mecmas21_7.x"],"outputKeys":["amesim_pnch023_1.m","amesim_pnch023_1.U","amesim_pnch023_1.p","amesim_pnch023_1.T","amesim_pnch023_1.rho","amesim_pnch023_1.u","amesim_pnch023_1.h","amesim_pnch023_1.port_1.p","amesim_pnch023_1.port_1.m_flow","amesim_pnch023_1.port_1.h_outflow","amesim_pnch023_1.port_2.p","amesim_pnch023_1.port_2.m_flow","amesim_pnch023_1.port_2.h_outflow","amesim_pnvo001_1.xv","amesim_pnvo001_1.cm","amesim_pnvo001_1.gasvel","amesim_pnvo001_1.res.signal","amesim_pnvo001_1.port_2.p","amesim_pnvo001_1.port_2.m_flow","amesim_pnvo001_1.port_2.h_outflow","amesim_pnvo001_1.port_3.p","amesim_pnvo001_1.port_3.m_flow","amesim_pnvo001_1.port_3.h_outflow","amesim_step0_1.y","amesim_step0_1.out.signal","amesim_pnch012_1.m","amesim_pnch012_1.U","amesim_pnch012_1.p","amesim_pnch012_1.T","amesim_pnch012_1.rho","amesim_pnch012_1.u","amesim_pnch012_1.h","amesim_pnch012_1.vol","amesim_pnch012_1.dvol","amesim_pnch012_1.port_1.p","amesim_pnch012_1.port_1.m_flow","amesim_pnch012_1.port_1.h_outflow","amesim_pnch012_1.port_2.p","amesim_pnch012_1.port_2.m_flow","amesim_pnch012_1.port_2.h_outflow","amesim_pnch012_1.port_3.p","amesim_pnch012_1.port_3.m_flow","amesim_pnch012_1.port_3.h_outflow","amesim_pnch012_1.port_4.p","amesim_pnch012_1.port_4.m_flow","amesim_pnch012_1.port_4.h_outflow","amesim_pnpl01_2.port_1.p","amesim_pnpl01_2.port_1.m_flow","amesim_pnpl01_2.port_1.h_outflow","amesim_pnpl01_3.port_1.p","amesim_pnpl01_3.port_1.m_flow","amesim_pnpl01_3.port_1.h_outflow","amesim_pnpl01_4.port_1.p","amesim_pnpl01_4.port_1.m_flow","amesim_pnpl01_4.port_1.h_outflow","amesim_ud00_1.y","amesim_ud00_1.out.signal","amesim_forc_1.force","amesim_forc_1.res.signal","amesim_forc_1.port_2.x","amesim_forc_1.port_2.v","amesim_forc_1.port_2.f","amesim_pnrp17_1.volume","amesim_pnrp17_1.volume_flow","amesim_pnrp17_1.length","amesim_pnrp17_1.pressure_force","amesim_pnrp17_1.port_1.p","amesim_pnrp17_1.port_1.m_flow","amesim_pnrp17_1.port_1.h_outflow","amesim_pnrp17_1.port_2.x","amesim_pnrp17_1.port_2.v","amesim_pnrp17_1.port_2.f","amesim_pnrp17_1.port_3.x","amesim_pnrp17_1.port_3.v","amesim_pnrp17_1.port_3.f","amesim_pnrp17_1.port_4.x","amesim_pnrp17_1.port_4.v","amesim_pnrp17_1.port_4.f","amesim_pnrp17_1.port_5.x","amesim_pnrp17_1.port_5.v","amesim_pnrp17_1.port_5.f","amesim_pnch012_2.m","amesim_pnch012_2.U","amesim_pnch012_2.p","amesim_pnch012_2.T","amesim_pnch012_2.rho","amesim_pnch012_2.u","amesim_pnch012_2.h","amesim_pnch012_2.vol","amesim_pnch012_2.dvol","amesim_pnch012_2.port_1.p","amesim_pnch012_2.port_1.m_flow","amesim_pnch012_2.port_1.h_outflow","amesim_pnch012_2.port_2.p","amesim_pnch012_2.port_2.m_flow","amesim_pnch012_2.port_2.h_outflow","amesim_pnch012_2.port_3.p","amesim_pnch012_2.port_3.m_flow","amesim_pnch012_2.port_3.h_outflow","amesim_pnch012_2.port_4.p","amesim_pnch012_2.port_4.m_flow","amesim_pnch012_2.port_4.h_outflow","amesim_pnpl01_6.port_1.p","amesim_pnpl01_6.port_1.m_flow","amesim_pnpl01_6.port_1.h_outflow","amesim_pnpl01_7.port_1.p","amesim_pnpl01_7.port_1.m_flow","amesim_pnpl01_7.port_1.h_outflow","amesim_pnpl01_8.port_1.p","amesim_pnpl01_8.port_1.m_flow","amesim_pnpl01_8.port_1.h_outflow","amesim_pnpl01_9.port_1.p","amesim_pnpl01_9.port_1.m_flow","amesim_pnpl01_9.port_1.h_outflow","amesim_mecmas21_2.a","amesim_mecmas21_2.v","amesim_mecmas21_2.x","amesim_mecmas21_2.Fvisc","amesim_mecmas21_2.Ffric","amesim_mecmas21_2.Fmin","amesim_mecmas21_2.Fmax","amesim_mecmas21_2.port_1.x","amesim_mecmas21_2.port_1.v","amesim_mecmas21_2.port_1.f","amesim_mecmas21_2.port_2.x","amesim_mecmas21_2.port_2.v","amesim_mecmas21_2.port_2.f","amesim_f000_1.port_1.x","amesim_f000_1.port_1.v","amesim_f000_1.port_1.f","amesim_f000_2.port_1.x","amesim_f000_2.port_1.v","amesim_f000_2.port_1.f","amesim_lstp00a_1.gap","amesim_lstp00a_1.penetration","amesim_lstp00a_1.force","amesim_lstp00a_1.port_1.x","amesim_lstp00a_1.port_1.v","amesim_lstp00a_1.port_1.f","amesim_lstp00a_1.port_2.x","amesim_lstp00a_1.port_2.v","amesim_lstp00a_1.port_2.f","amesim_forc_2.force","amesim_forc_2.res.signal","amesim_forc_2.port_2.x","amesim_forc_2.port_2.v","amesim_forc_2.port_2.f","amesim_ud00_2.y","amesim_ud00_2.out.signal","amesim_mecmas21_5.a","amesim_mecmas21_5.v","amesim_mecmas21_5.x","amesim_mecmas21_5.Fvisc","amesim_mecmas21_5.Ffric","amesim_mecmas21_5.Fmin","amesim_mecmas21_5.Fmax","amesim_mecmas21_5.port_1.x","amesim_mecmas21_5.port_1.v","amesim_mecmas21_5.port_1.f","amesim_mecmas21_5.port_2.x","amesim_mecmas21_5.port_2.v","amesim_mecmas21_5.port_2.f","amesim_mecmas21_7.a","amesim_mecmas21_7.v","amesim_mecmas21_7.x","amesim_mecmas21_7.Fvisc","amesim_mecmas21_7.Ffric","amesim_mecmas21_7.Fmin","amesim_mecmas21_7.Fmax","amesim_mecmas21_7.port_1.x","amesim_mecmas21_7.port_1.v","amesim_mecmas21_7.port_1.f","amesim_mecmas21_7.port_2.x","amesim_mecmas21_7.port_2.v","amesim_mecmas21_7.port_2.f"],"initialState":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"probes":[{"time":0,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[0.0,0.0,0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,5882352.94117647,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,0.0,0.015770552842049427,0.0,0.0,15299999.999999808,0.0,-25942.86971068256,100000.0,0.0,-10744.47227494699,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1e+17,1e+17,1e+17,1e+17,0.0,0.0,-1e+17,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,1000000000000.0,1000000000000.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17]},{"time":1,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,-0.02,0.37000005,0.0,0.37,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,46.58068575414885,-28.25168356955217,-0.02,0.0,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.011623603791718838,-0.0006283028227546908,0.37000005,-1412.5841784776085,56334.92496550104,0.0,-26008.353885226727,0.37000005,-0.02,1412.5841784776085,0.0,0.0,-1412.5841784776085,0.0,0.0,0.0,0.37000005,-0.02,0.0,0.002462855848963059,-1563.893548405841,56334.92496550104,293.13934733963936,0.09250647914649031,-634991.9135804437,-26008.353885226727,0.02662360379171884,-0.0006283028227546908,56334.92496550104,0.0,-26008.353885226727,56334.92496550104,0.0,-26008.353885226727,56334.92496550104,0.0,-26008.353885226727,56334.92496550104,0.0,-26008.353885226727,15299999.999999808,0.0,-10744.47227494699,56334.92496550104,0.0,-26008.353885226727,56334.92496550104,0.0,-26008.353885226727,56334.92496550104,0.0,-26008.353885226727,-28.25168356955217,-0.02,0.37000005,0.0,0.0,0.0,0.0,0.37000005,-0.02,-1412.5841784776085,0.37000005,-0.02,0.0,0.37000005,-0.02,0.0,0.0,0.0,0.0,-5.000000002919336e-08,5.000000002919336e-08,0.0,0.37000005,-0.02,0.0,0.37,0.0,0.0,0.0,0.0,0.37,0.0,0.0,0.0,0.0,0.0,0.0,0.37,0.0,0.0,0.0,0.0,0.37,0.0,0.0,0.37,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1412.5841784776085,0.0,0.0,49000.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.cm","target.gasvel","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0016031171261321375,-182.1921393748713,0.0016031171261321375,182.1921393748713],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0016031171261321375,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0016031171261321375,-94220.31719333242,200000.0,0.0,-94220.31719333242,0.01593190608855804,912.0781924140919,499999.9999999994,0.0016031171261321375,-94220.31719333242,200000.0,-0.0016031171261321375,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334],"rhs":[0.0006855399965272192,-64.5917959215106,-0.0006855399965272192,64.5917959215106],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.0006855399965272192,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006855399965272192,-94220.31719333242,200000.0,0.0,-94220.31719333242,0.015932330880128697,-852.8646604206053,24972.285381288722,-0.0006855399965272192,-94220.31719333242,200000.0,0.0006855399965272192,112911.17781875384,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnvo001_fixed","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0016031171261321375,-182.1921393748713,0.0016031171261321375,182.1921393748713],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0016031171261321375,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0016031171261321375,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,499999.9999999994,0.0016031171261321375,-94220.31719333242,200000.0,-0.0016031171261321375,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334],"rhs":[0.0006855399965272192,-64.5917959215106,-0.0006855399965272192,64.5917959215106],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.0006855399965272192,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006855399965272192,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.015932330880128697,-852.8646604206053,24972.285381288722,-0.0006855399965272192,-94220.31719333242,200000.0,0.0006855399965272192,112911.17781875384,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnvo001","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.res.signal","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0016031171261321375,-182.1921393748713,0.0016031171261321375,182.1921393748713],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0016031171261321375,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0016031171261321375,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,1.0,499999.9999999994,0.0016031171261321375,-94220.31719333242,200000.0,-0.0016031171261321375,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334],"rhs":[0.0006855399965272192,-64.5917959215106,-0.0006855399965272192,64.5917959215106],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.0006855399965272192,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006855399965272192,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.015932330880128697,-852.8646604206053,1.0,24972.285381288722,-0.0006855399965272192,-94220.31719333242,200000.0,0.0006855399965272192,112911.17781875384,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl00r","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.027626607013824682,-3139.727318403476,0.027626607013824682,3139.727318403476],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.027626607013824682,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.027626607013824682,-94220.31719333242,200000.0,0.0,-94220.31719333242,168699.95638581843,0.01593190608724899,468.01514636699926,0.016026923675216576,499999.9999999994,0.027626607013824682,-94220.31719333242,200000.0,-0.027626607013824682,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334],"rhs":[0.010829625020355768,-1020.3707045027695,-0.010829625020355768,1020.3707045027695],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.010829625020355768,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.010829625020355768,-94220.31719333242,200000.0,0.0,-94220.31719333242,72704.07818075607,0.01593233087881416,-401.1314793598657,0.019072758882143156,24972.285381288722,-0.010829625020355768,-94220.31719333242,200000.0,0.010829625020355768,112911.17781875384,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl0001","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"guard_1","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["guard_1","port_1"],["right","port_1"]],[["guard_1","port_2"],["target","port_2"]],[["cap_4","port_1"],["left","port_2"]],[["cap_5","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m","target.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","guard_1.cm","guard_1.gasvel","guard_1.port_1.p","guard_1.port_1.m_flow","guard_1.port_1.h_outflow","guard_1.port_2.p","guard_1.port_2.m_flow","guard_1.port_2.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[-0.029197697327668193,-3318.2796529549155,-0.0006853326572107992,64.57226034535086,0.029883029984878993,3253.7073926095645],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.029197697327668193,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,189671.74924895575,0.015931229543169473,453.1383439717506,0.01566142441121615,499999.9999999994,0.029197697327668193,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[0.004809602659253327,-124.77489514916142,-0.0006853326572107992,64.57226034535086,-0.004124270002042528,60.20263480381057],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.004809602659253327,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,31243.756633799665,0.015932918924661397,-372.9680100524177,0.023092032421793744,24972.285381288722,-0.004809602659253327,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl0002","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m","target.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[-0.04055277292537836,-4608.768964176763,-0.01595085240764607,1502.894373352443,0.05650362533302443,3105.87459082432],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.04055277292537836,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.01595085240764607,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,177358.92027751828,0.015929256534807118,638.9083600741285,0.01622365698711957,499999.9999999994,0.04055277292537836,-25942.86971068256,200000.0,0.01595085240764607,-25942.86971068256,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[0.007118599370016211,-184.67689597887752,-0.01595085240764607,1502.894373352443,0.00883225303762986,-1318.2174773735653],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.007118599370016211,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.01595085240764607,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,76664.2133534304,0.01593015061627443,571.4227331820985,0.01932745350919801,24972.285381288722,-0.007118599370016211,-25942.86971068256,200000.0,0.01595085240764607,-25942.86971068256,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl0003","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p1_0":100000.0,"T1_0":293.15,"p2_0":100000.0,"T2_0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"guard_0","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"guard_1","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["guard_0","port_1"],["left","port_1"]],[["guard_0","port_2"],["target","port_1"]],[["guard_1","port_1"],["right","port_1"]],[["guard_1","port_2"],["target","port_2"]],[["cap_5","port_1"],["left","port_2"]],[["cap_6","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m1","target.U1","target.m2","target.U2"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m1","target.U1","target.p1","target.T1","target.rho1","target.u1","target.h1","target.m2","target.U2","target.p2","target.T2","target.rho2","target.u2","target.h2","target.dmctr","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","guard_0.cm","guard_0.gasvel","guard_0.port_1.p","guard_0.port_1.m_flow","guard_0.port_1.h_outflow","guard_0.port_2.p","guard_0.port_2.m_flow","guard_0.port_2.h_outflow","guard_1.cm","guard_1.gasvel","guard_1.port_1.p","guard_1.port_1.m_flow","guard_1.port_1.h_outflow","guard_1.port_2.p","guard_1.port_2.m_flow","guard_1.port_2.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow","cap_6.port_1.p","cap_6.port_1.m_flow","cap_6.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887],"rhs":[-0.0016030460116216263,-182.18405730488436,-0.0006853326572107992,64.57226034535086,0.0016030460116216263,182.18405730488436,0.0006853326572107992,-64.57226034535086],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0016030460116216263,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,0.0016030460116216263,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.01593119934687053,912.0551273376216,499999.9999999994,0.0016030460116216263,-25942.86971068256,99999.99999999999,-0.0016030460116216263,113648.67632251472,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887],"rhs":[0.000335004235645511,-8.69097123787829,-0.0006853326572107992,64.57226034535086,-0.000335004235645511,8.69097123787829,0.0006853326572107992,-64.57226034535086],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.000335004235645511,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,-0.000335004235645511,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.015932823552715785,-872.5625146765855,24972.285381288722,-0.000335004235645511,-25942.86971068256,99999.99999999999,0.000335004235645511,112911.17781875384,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"pipe","parameters":{"length":5.0,"diameter":0.02,"lambda_darcy":0.02,"p0":100000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_a"]],[["right","port_1"],["target","port_b"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.port_a.p","target.port_a.m_flow","target.port_a.h_outflow","target.port_b.p","target.port_b.m_flow","target.port_b.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.08153491541050194,-9266.335210471752,0.08153491541050195,9266.335210471754],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.08153491541050194,113648.67632251472,499999.9999999994,2.919528130092096e-27,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.08153491541050195,-94220.31719333242,200000.0,2.6121266719614384e-27,-94220.31719333242,499999.9999999994,0.08153491541050194,-94220.31719333242,200000.0,-0.08153491541050194,113648.67632251472,499999.9999999994,-4.49337280605919e-30,113648.67632251472,200000.0,2.332569772386756e-30,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334],"rhs":[0.035313175845474665,-3327.2186292645474,-0.035313175845474665,3327.2186292645474],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.035313175845474665,112911.17781875384,24972.285381288722,-7.203972573287165e-27,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.035313175845474665,-94220.31719333242,200000.0,-9.767483520196133e-29,-94220.31719333242,24972.28538128885,-0.035313175845474665,-94220.31719333242,200000.0,0.035313175845474665,112911.17781875384,24972.285381288722,-2.522784507739877e-29,112911.17781875384,200000.0,-1.2429132175961872e-29,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"orifice","parameters":{"K":1e-05,"opening":1.0},"overrides":{},"medium":null},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_a"]],[["right","port_1"],["target","port_b"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.port_a.p","target.port_a.m_flow","target.port_a.h_outflow","target.port_b.p","target.port_b.m_flow","target.port_b.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.005477225575051656,-622.4794365244452,0.005477225575051656,622.4794365244452],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.005477225575051656,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.005477225575051656,-94220.31719333242,200000.0,0.0,-94220.31719333242,499999.9999999994,0.005477225575051656,-94220.31719333242,200000.0,-0.005477225575051656,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.0021420155545871376,-1181.5627675589315,0.019593483179050245,-13246.104200052334],"rhs":[0.004183631372608148,-394.1830749471164,-0.004183631372608148,394.1830749471164],"outputs":[0.0021420155545871376,-1181.5627675589315,24972.285381288722,319.8906935272419,0.037579220255914696,-551612.5991842629,112911.17781875384,24972.285381288722,0.004183631372608148,112911.17781875384,24972.285381288722,0.0,112911.17781875384,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.004183631372608148,-94220.31719333242,200000.0,0.0,-94220.31719333242,24972.285381288722,-0.004183631372608148,-94220.31719333242,200000.0,0.004183631372608148,112911.17781875384,24972.285381288722,0.0,112911.17781875384,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.cm","target.gasvel","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"rhs":[-0.004066745596706244,-1307.8653839007281,0.004066745596706244,1307.8653839007281],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.004066745596706244,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,0.004066745596706244,281400.0,200000.00000000003,0.0,281400.0,0.04041564267303603,327.31908428433906,500000.0,0.004066745596706244,281400.0,200000.00000000003,-0.004066745596706244,321600.0,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706],"rhs":[0.0017390135659481518,489.35841745780994,-0.0017390135659481518,-489.35841745780994],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.0017390135659481518,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0017390135659481518,281400.0,200000.00000000003,0.0,281400.0,0.040415642673036045,-306.1789673861449,25000.0,-0.0017390135659481518,281400.0,200000.00000000003,0.0017390135659481518,321600.0,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"amesim_pnvo001_fixed","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"rhs":[-0.004066745596706244,-1307.8653839007281,0.004066745596706244,1307.8653839007281],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.004066745596706244,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,0.004066745596706244,281400.0,200000.00000000003,0.0,281400.0,1.0,0.04041564267303603,327.31908428433906,500000.0,0.004066745596706244,281400.0,200000.00000000003,-0.004066745596706244,321600.0,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706],"rhs":[0.0017390135659481518,489.35841745780994,-0.0017390135659481518,-489.35841745780994],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.0017390135659481518,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0017390135659481518,281400.0,200000.00000000003,0.0,281400.0,1.0,0.040415642673036045,-306.1789673861449,25000.0,-0.0017390135659481518,281400.0,200000.00000000003,0.0017390135659481518,321600.0,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"amesim_pnvo001","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.res.signal","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"rhs":[-0.004066745596706244,-1307.8653839007281,0.004066745596706244,1307.8653839007281],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.004066745596706244,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,0.004066745596706244,281400.0,200000.00000000003,0.0,281400.0,1.0,0.04041564267303603,327.31908428433906,1.0,500000.0,0.004066745596706244,281400.0,200000.00000000003,-0.004066745596706244,321600.0,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706],"rhs":[0.0017390135659481518,489.35841745780994,-0.0017390135659481518,-489.35841745780994],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.0017390135659481518,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0017390135659481518,281400.0,200000.00000000003,0.0,281400.0,1.0,0.040415642673036045,-306.1789673861449,1.0,25000.0,-0.0017390135659481518,281400.0,200000.00000000003,0.0017390135659481518,321600.0,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"amesim_pnl00r","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"rhs":[-0.07774708146707801,-25003.461399812288,0.07774708146707801,25003.461399812288],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.07774708146707801,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,0.07774708146707801,281400.0,200000.00000000003,0.0,281400.0,508636.61118156323,0.04041564267069371,181.82604173784202,0.013022686361929407,500000.0,0.07774708146707801,281400.0,200000.00000000003,-0.07774708146707801,321600.0,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706],"rhs":[0.030800723134701974,8667.323490105135,-0.030800723134701974,-8667.323490105135],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.030800723134701974,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.030800723134701974,281400.0,200000.00000000003,0.0,281400.0,223310.88961575,0.04041564266932544,-157.57269538279465,0.01517256040307057,25000.0,-0.030800723134701974,281400.0,200000.00000000003,0.030800723134701974,321600.0,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"amesim_pnl0001","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"guard_1","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["guard_1","port_1"],["right","port_1"]],[["guard_1","port_2"],["target","port_2"]],[["cap_4","port_1"],["left","port_2"]],[["cap_5","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m","target.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","guard_1.cm","guard_1.gasvel","guard_1.port_1.p","guard_1.port_1.m_flow","guard_1.port_1.h_outflow","guard_1.port_2.p","guard_1.port_2.m_flow","guard_1.port_2.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706,9.335080902410481e-05,19.64863697976892],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706,9.335080902410481e-05,19.64863697976892],"rhs":[-0.08211340173768092,-26407.669998838184,-0.0017390135659481518,-489.35841745780994,0.08385241530362907,26897.028416295994],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.08211340173768092,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0017390135659481518,281400.0,200000.00000000003,0.0,281400.0,9.335080902410481e-05,19.64863697976892,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,574450.7156326394,0.04041519289952498,175.92434944292302,0.012743569639731134,500000.0,0.08211340173768092,294615.75,100000.0,0.0017390135659481518,294615.75,0.040415642673036045,306.1789673861449,200000.00000000003,0.0017390135659481518,294615.75,100000.0,-0.0017390135659481518,281400.0,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706,9.335080902410481e-05,19.64863697976892],"rhs":[0.013839286286402706,4077.271708733248,-0.0017390135659481518,-489.35841745780994,-0.012100272720454554,-3587.913291275438],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.013839286286402706,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0017390135659481518,281400.0,200000.00000000003,0.0,281400.0,9.335080902410481e-05,19.64863697976892,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,96817.17896021408,0.040415498501130166,-148.25030903405627,0.017945615449559558,25000.0,-0.013839286286402706,294615.75,100000.0,0.0017390135659481518,294615.75,0.040415642673036045,306.1789673861449,200000.00000000003,0.0017390135659481518,294615.75,100000.0,-0.0017390135659481518,281400.0,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"amesim_pnl0002","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m","target.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706,9.335080902410481e-05,19.64863697976892],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706,9.335080902410481e-05,19.64863697976892],"rhs":[-0.11369340392990607,-36563.79870385779,-0.045165587868369744,-12709.596426159245,0.1588589917982758,49273.39513001704],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.11369340392990607,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.045165587868369744,281400.0,200000.00000000003,0.0,281400.0,9.335080902410481e-05,19.64863697976892,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,535631.6374668423,0.04041521479597848,248.47739311305673,0.013145567194632175,500000.0,0.11369340392990607,294615.75,200000.00000000003,0.045165587868369744,294615.75,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706,9.335080902410481e-05,19.64863697976892],"rhs":[0.020363442003817112,5999.390738536082,-0.045165587868369744,-12709.596426159245,0.024802145864552632,6710.205687623164],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.020363442003817112,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.045165587868369744,281400.0,200000.00000000003,0.0,281400.0,9.335080902410481e-05,19.64863697976892,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,234958.9007351864,0.04041533762612679,224.60023034755972,0.015344584025148253,25000.0,-0.020363442003817112,294615.75,200000.00000000003,0.045165587868369744,294615.75,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"amesim_pnl0003","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p1_0":100000.0,"T1_0":293.15,"p2_0":100000.0,"T2_0":293.15},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"guard_0","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"guard_1","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["guard_0","port_1"],["left","port_1"]],[["guard_0","port_2"],["target","port_1"]],[["guard_1","port_1"],["right","port_1"]],[["guard_1","port_2"],["target","port_2"]],[["cap_5","port_1"],["left","port_2"]],[["cap_6","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m1","target.U1","target.m2","target.U2"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m1","target.U1","target.p1","target.T1","target.rho1","target.u1","target.h1","target.m2","target.U2","target.p2","target.T2","target.rho2","target.u2","target.h2","target.dmctr","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","guard_0.cm","guard_0.gasvel","guard_0.port_1.p","guard_0.port_1.m_flow","guard_0.port_1.h_outflow","guard_0.port_2.p","guard_0.port_2.m_flow","guard_0.port_2.h_outflow","guard_1.cm","guard_1.gasvel","guard_1.port_1.p","guard_1.port_1.m_flow","guard_1.port_1.h_outflow","guard_1.port_2.p","guard_1.port_2.m_flow","guard_1.port_2.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow","cap_6.port_1.p","cap_6.port_1.m_flow","cap_6.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706,4.6675404512052404e-05,9.82431848988446,4.6675404512052404e-05,9.82431848988446],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706,4.6675404512052404e-05,9.82431848988446,4.6675404512052404e-05,9.82431848988446],"rhs":[-0.004066745596706244,-1307.8653839007281,-0.0017390135659481518,-489.35841745780994,0.004066745596706244,1307.8653839007281,0.0017390135659481518,489.35841745780994],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.004066745596706244,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0017390135659481518,281400.0,200000.00000000003,0.0,281400.0,4.6675404512052404e-05,9.82431848988446,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,4.6675404512052404e-05,9.82431848988446,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,0.0,0.0,0.0,0.0,64000000.0,100000.0,0.004066745596706244,294615.75,100000.0,0.0017390135659481518,294615.75,0.04041564267303603,327.31908428433906,500000.0,0.004066745596706244,294615.75,100000.0,-0.004066745596706244,321600.0,0.040415642673036045,306.1789673861449,200000.00000000003,0.0017390135659481518,294615.75,100000.0,-0.0017390135659481518,281400.0,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706,4.6675404512052404e-05,9.82431848988446,4.6675404512052404e-05,9.82431848988446],"rhs":[0.0008497810470947389,250.35888052560182,-0.0017390135659481518,-489.35841745780994,-0.0008497810470947389,-250.35888052560182,0.0017390135659481518,489.35841745780994],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.0008497810470947389,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0017390135659481518,281400.0,200000.00000000003,0.0,281400.0,4.6675404512052404e-05,9.82431848988446,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,4.6675404512052404e-05,9.82431848988446,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,0.0,0.0,0.0,0.0,64000000.0,100000.0,-0.0008497810470947389,294615.75,100000.0,0.0017390135659481518,294615.75,0.040415642673036045,-313.28621632175674,25000.0,-0.0008497810470947389,294615.75,100000.0,0.0008497810470947389,321600.0,0.040415642673036045,306.1789673861449,200000.00000000003,0.0017390135659481518,294615.75,100000.0,-0.0017390135659481518,281400.0,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"pipe","parameters":{"length":5.0,"diameter":0.02,"lambda_darcy":0.02,"p0":100000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_a"]],[["right","port_1"],["target","port_b"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.port_a.p","target.port_a.m_flow","target.port_a.h_outflow","target.port_b.p","target.port_b.m_flow","target.port_b.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"rhs":[-0.21941834862342463,-70564.94091729337,0.21941834862342463,70564.94091729337],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.21941834862342463,321600.0,500000.0,-2.0775433538932823e-24,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,0.21941834862342463,281400.0,200000.00000000003,5.836745999024611e-25,281400.0,500000.0,0.21941834862342463,281400.0,200000.00000000597,-0.21941834862342463,321600.0,500000.0,-8.897449157844626e-28,321600.0,200000.00000000003,-5.005820528007576e-28,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706],"rhs":[0.0950109319821436,26736.076259775207,-0.0950109319821436,-26736.076259775207],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.0950109319821436,321600.0,25000.0,-2.5599840382199503e-25,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.0950109319821436,281400.0,200000.00000000003,-3.9417665832911114e-27,281400.0,25000.00000045431,-0.0950109319821436,281400.0,200000.00000000003,0.0950109319821436,321600.0,25000.0,-1.3841138984047744e-27,321600.0,200000.00000000003,2.2320559506228833e-28,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"target","type":"orifice","parameters":{"K":1e-05,"opening":1.0},"overrides":{},"medium":null},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_a"]],[["right","port_1"],["target","port_b"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.port_a.p","target.port_a.m_flow","target.port_a.h_outflow","target.port_b.p","target.port_b.m_flow","target.port_b.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"probes":[{"time":0,"state":[0.31032229965156793,71299.65156794425,0.14186162269785965,28519.860627177706],"rhs":[-0.005477225575051662,-1761.4757449366145,0.005477225575051662,1761.4757449366145],"outputs":[0.31032229965156793,71299.65156794425,500000.0,320.0,5.444250871080139,229760.0,321600.0,500000.0,-0.005477225575051662,321600.0,500000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,0.005477225575051662,281400.0,200000.00000000003,0.0,281400.0,500000.0,0.005477225575051662,281400.0,200000.00000000003,-0.005477225575051662,321600.0,500000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]},{"time":0.7,"state":[0.015516114982578398,3564.982578397213,0.14186162269785965,28519.860627177706],"rhs":[0.004183300132670378,1177.1806573334445,-0.004183300132670378,-1177.1806573334445],"outputs":[0.015516114982578398,3564.982578397213,25000.0,320.0,0.272212543554007,229760.0,321600.0,25000.0,0.004183300132670378,321600.0,25000.0,0.0,321600.0,0.14186162269785965,28519.860627177706,200000.00000000003,280.0,2.4888003982080638,201040.0,281400.0,200000.00000000003,-0.004183300132670378,281400.0,200000.00000000003,0.0,281400.0,25000.0,-0.004183300132670378,281400.0,200000.00000000003,0.004183300132670378,321600.0,25000.0,0.0,321600.0,200000.00000000003,0.0,281400.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":2.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.cm","target.gasvel","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0037809670408874155,-429.70189941591013,0.0037809670408874155,429.70189941591013],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0037809670408874155,-94220.31719333242,200000.0,0.0,-94220.31719333242,0.01593190608855804,912.0781924140919,499999.9999999994,0.0037809670408874155,-94220.31719333242,200000.0,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0037809670408874155,-429.70189941591013,0.0037809670408874155,429.70189941591013],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0037809670408874155,-94220.31719333242,200000.0,0.0,-94220.31719333242,0.01593190608855804,912.0781924140919,499999.9999999994,0.0037809670408874155,-94220.31719333242,200000.0,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnvo001_fixed","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":2.0,"opening":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0037809670408874155,-429.70189941591013,0.0037809670408874155,429.70189941591013],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0037809670408874155,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,499999.9999999994,0.0037809670408874155,-94220.31719333242,200000.0,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0037809670408874155,-429.70189941591013,0.0037809670408874155,429.70189941591013],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0037809670408874155,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,499999.9999999994,0.0037809670408874155,-94220.31719333242,200000.0,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnvo001","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":2.0,"opening0":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.res.signal","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0037809670408874155,-429.70189941591013,0.0037809670408874155,429.70189941591013],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0037809670408874155,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,1.0,499999.9999999994,0.0037809670408874155,-94220.31719333242,200000.0,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.0037809670408874155,-429.70189941591013,0.0037809670408874155,429.70189941591013],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.0037809670408874155,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,1.0,499999.9999999994,0.0037809670408874155,-94220.31719333242,200000.0,-0.0037809670408874155,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":3.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.cm","target.gasvel","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.003496938461916961,-397.42242737815315,0.003496938461916961,397.42242737815315],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.003496938461916961,-94220.31719333242,200000.0,0.0,-94220.31719333242,0.01593190608855804,912.0781924140919,499999.9999999994,0.003496938461916961,-94220.31719333242,200000.0,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.003496938461916961,-397.42242737815315,0.003496938461916961,397.42242737815315],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.003496938461916961,-94220.31719333242,200000.0,0.0,-94220.31719333242,0.01593190608855804,912.0781924140919,499999.9999999994,0.003496938461916961,-94220.31719333242,200000.0,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnvo001_fixed","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":3.0,"opening":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.003496938461916961,-397.42242737815315,0.003496938461916961,397.42242737815315],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.003496938461916961,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,499999.9999999994,0.003496938461916961,-94220.31719333242,200000.0,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.003496938461916961,-397.42242737815315,0.003496938461916961,397.42242737815315],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.003496938461916961,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,499999.9999999994,0.003496938461916961,-94220.31719333242,200000.0,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnvo001","parameters":{"gi":0.0,"cq":0.72,"area0":5e-06,"Cv":0.5,"Kv":0.4,"flowset":3.0,"opening0":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_2"]],[["right","port_1"],["target","port_3"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.xv","target.cm","target.gasvel","target.res.signal","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.003496938461916961,-397.42242737815315,0.003496938461916961,397.42242737815315],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.003496938461916961,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,1.0,499999.9999999994,0.003496938461916961,-94220.31719333242,200000.0,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.003496938461916961,-397.42242737815315,0.003496938461916961,397.42242737815315],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.003496938461916961,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.0,0.01593190608855804,912.0781924140919,1.0,499999.9999999994,0.003496938461916961,-94220.31719333242,200000.0,-0.003496938461916961,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl00r","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.027625937914583863,-3139.6512761604286,0.027625937914583863,3139.6512761604286],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.027625937914583863,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.027625937914583863,-94220.31719333242,200000.0,0.0,-94220.31719333242,168695.8705777897,0.015931906087251153,468.003811331214,0.016027700027673435,499999.9999999994,0.027625937914583863,-94220.31719333242,200000.0,-0.027625937914583863,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334],"rhs":[-0.027625937914583863,-3139.6512761604286,0.027625937914583863,3139.6512761604286],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.027625937914583863,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,0.027625937914583863,-94220.31719333242,200000.0,0.0,-94220.31719333242,168695.8705777897,0.015931906087251153,468.003811331214,0.016027700027673435,499999.9999999994,0.027625937914583863,-94220.31719333242,200000.0,-0.027625937914583863,113648.67632251472,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl0001","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":0.0,"k":1.35,"kth":5.0,"extemp":350.0,"mode":2.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"guard_1","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["guard_1","port_1"],["right","port_1"]],[["guard_1","port_2"],["target","port_2"]],[["cap_4","port_1"],["left","port_2"]],[["cap_5","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m","target.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","guard_1.cm","guard_1.gasvel","guard_1.port_1.p","guard_1.port_1.m_flow","guard_1.port_1.h_outflow","guard_1.port_2.p","guard_1.port_2.m_flow","guard_1.port_2.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[-0.029196825860168876,-3318.1806118271597,-0.0006853326572107992,64.57226034535086,0.029882158517379676,3262.5383285996377],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.029196825860168876,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,189666.0880913941,0.0159312296425118,453.12481909220156,0.015662359545579825,499999.9999999994,0.029196825860168876,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[-0.029196825860168876,-3318.1806118271597,-0.0006853326572107992,64.57226034535086,0.029882158517379676,3262.5383285996377],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.029196825860168876,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,189666.0880913941,0.0159312296425118,453.12481909220156,0.015662359545579825,499999.9999999994,0.029196825860168876,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl0002","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":0.0,"k":1.35,"kth":5.0,"extemp":350.0,"mode":2.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["left","port_1"],["target","port_1"]],[["right","port_1"],["target","port_2"]],[["cap_3","port_1"],["left","port_2"]],[["cap_4","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m","target.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[-0.0405508310059587,-4608.548267605194,-0.01595068202963253,1502.8783202819643,0.05650151303559123,3114.599924441059],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0405508310059587,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.01595068202963253,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,177352.41926790384,0.015929256635312868,638.8887558809718,0.016224557227755954,499999.9999999994,0.0405508310059587,-25942.86971068256,200000.0,0.01595068202963253,-25942.86971068256,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,1.2895483069921667e-05,-8.188527471113774],"rhs":[-0.0405508310059587,-4608.548267605194,-0.01595068202963253,1502.8783202819643,0.05650151303559123,3114.599924441059],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0405508310059587,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.01595068202963253,-94220.31719333242,200000.0,0.0,-94220.31719333242,1.2895483069921667e-05,-8.188527471113774,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,177352.41926790384,0.015929256635312868,638.8887558809718,0.016224557227755954,499999.9999999994,0.0405508310059587,-25942.86971068256,200000.0,0.01595068202963253,-25942.86971068256,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":320.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"target","type":"amesim_pnl0003","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":0.0,"k":1.35,"kth":5.0,"extemp":350.0,"mode":2.0,"p1_0":100000.0,"T1_0":293.15,"p2_0":100000.0,"T2_0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"guard_0","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"guard_1","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["guard_0","port_1"],["left","port_1"]],[["guard_0","port_2"],["target","port_1"]],[["guard_1","port_1"],["right","port_1"]],[["guard_1","port_2"],["target","port_2"]],[["cap_5","port_1"],["left","port_2"]],[["cap_6","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U","target.m1","target.U1","target.m2","target.U2"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","target.m1","target.U1","target.p1","target.T1","target.rho1","target.u1","target.h1","target.m2","target.U2","target.p2","target.T2","target.rho2","target.u2","target.h2","target.dmctr","target.re","target.cm","target.v","target.ff","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","guard_0.cm","guard_0.gasvel","guard_0.port_1.p","guard_0.port_1.m_flow","guard_0.port_1.h_outflow","guard_0.port_2.p","guard_0.port_2.m_flow","guard_0.port_2.h_outflow","guard_1.cm","guard_1.gasvel","guard_1.port_1.p","guard_1.port_1.m_flow","guard_1.port_1.h_outflow","guard_1.port_2.p","guard_1.port_2.m_flow","guard_1.port_2.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow","cap_6.port_1.p","cap_6.port_1.m_flow","cap_6.port_1.h_outflow"],"initialState":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887],"probes":[{"time":0,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887],"rhs":[-0.0016030460116216263,-182.18405730488436,-0.0006853326572107992,64.57226034535086,0.0016030460116216263,186.64904586379885,0.0006853326572107992,-60.10727178643636],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0016030460116216263,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,0.0016030460116216263,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.01593119934687053,912.0551273376216,499999.9999999994,0.0016030460116216263,-25942.86971068256,99999.99999999999,-0.0016030460116216263,113648.67632251472,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]},{"time":0.7,"state":[0.04284031109174275,-23631.25535117863,0.019593483179050245,-13246.104200052334,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887],"rhs":[-0.0016030460116216263,-182.18405730488436,-0.0006853326572107992,64.57226034535086,0.0016030460116216263,186.64904586379885,0.0006853326572107992,-60.10727178643636],"outputs":[0.04284031109174275,-23631.25535117863,499999.9999999994,319.9999999999997,0.7515844051182938,-551612.5991842629,113648.67632251472,499999.9999999994,-0.0016030460116216263,113648.67632251472,499999.9999999994,0.0,113648.67632251472,0.019593483179050245,-13246.104200052334,200000.0,279.99999999999994,0.34374531893070603,-676046.4221193371,-94220.31719333242,200000.0,-0.0006853326572107992,-94220.31719333242,200000.0,0.0,-94220.31719333242,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,0.0016030460116216263,-25942.86971068256,99999.99999999999,0.0006853326572107992,-25942.86971068256,0.01593119934687053,912.0551273376216,499999.9999999994,0.0016030460116216263,-25942.86971068256,99999.99999999999,-0.0016030460116216263,113648.67632251472,0.015927512199073766,839.2598754109764,200000.0,0.0006853326572107992,-25942.86971068256,99999.99999999999,-0.0006853326572107992,-94220.31719333242,499999.9999999994,0.0,113648.67632251472,200000.0,0.0,-94220.31719333242]}]},{"components":[{"name":"node","type":"amesim_pn3node2","parameters":{},"overrides":{},"medium":null},{"name":"s0","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":270.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r0","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"s1","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":300000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"s2","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":400000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r2","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_7","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r0","port_1"],["s0","port_1"]],[["node","port_1"],["r0","port_2"]],[["node","port_2"],["s1","port_1"]],[["r2","port_1"],["s2","port_1"]],[["node","port_3"],["r2","port_2"]],[["cap_6","port_1"],["s0","port_2"]],[["cap_7","port_1"],["s1","port_2"]],[["cap_8","port_1"],["s2","port_2"]]],"stateKeys":["s0.m","s0.U","s1.m","s1.U","s2.m","s2.U"],"outputKeys":["node.port_1.p","node.port_1.m_flow","node.port_1.h_outflow","node.port_2.p","node.port_2.m_flow","node.port_2.h_outflow","node.port_3.p","node.port_3.m_flow","node.port_3.h_outflow","s0.m","s0.U","s0.p","s0.T","s0.rho","s0.u","s0.h","s0.port_1.p","s0.port_1.m_flow","s0.port_1.h_outflow","s0.port_2.p","s0.port_2.m_flow","s0.port_2.h_outflow","r0.cm","r0.gasvel","r0.port_1.p","r0.port_1.m_flow","r0.port_1.h_outflow","r0.port_2.p","r0.port_2.m_flow","r0.port_2.h_outflow","s1.m","s1.U","s1.p","s1.T","s1.rho","s1.u","s1.h","s1.port_1.p","s1.port_1.m_flow","s1.port_1.h_outflow","s1.port_2.p","s1.port_2.m_flow","s1.port_2.h_outflow","s2.m","s2.U","s2.p","s2.T","s2.rho","s2.u","s2.h","s2.port_1.p","s2.port_1.m_flow","s2.port_1.h_outflow","s2.port_2.p","s2.port_2.m_flow","s2.port_2.h_outflow","r2.cm","r2.gasvel","r2.port_1.p","r2.port_1.m_flow","r2.port_1.h_outflow","r2.port_2.p","r2.port_2.m_flow","r2.port_2.h_outflow","cap_6.port_1.p","cap_6.port_1.m_flow","cap_6.port_1.h_outflow","cap_7.port_1.p","cap_7.port_1.m_flow","cap_7.port_1.h_outflow","cap_8.port_1.p","cap_8.port_1.m_flow","cap_8.port_1.h_outflow"],"initialState":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942],"probes":[{"time":0,"state":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942],"rhs":[0.0009279701322459646,8.990304464047412,0.00015074727158151846,169.59846037964132,-0.001078717403827483,-178.58876484368872],"outputs":[299999.99999999994,-0.0009279701322459646,9688.139899813577,299999.99999999994,-0.00015074727158151846,1125051.608565458,299999.99999999994,0.001078717403827483,9688.139899813577,0.020319124506141158,-14369.869520404944,200000.0,270.0,0.3564758685287922,-707209.0884654927,-146161.29349014745,200000.0,0.0009279701322459646,-146161.29349014745,200000.0,0.0,-146161.29349014745,0.014882327934781673,-683.1483154393907,200000.0,-0.0009279701322459646,9688.139899813577,299999.99999999994,0.0009279701322459646,-146161.29349014745,0.027426500211483972,-16834.288228988866,299999.99999999994,299.99999999999994,0.4811666703769118,-613796.4413680475,9688.139899813577,299999.99999999994,0.00015074727158151846,9688.139899813577,299999.99999999994,0.0,9688.139899813577,0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.001078717403827483,165556.58062994416,400000.0,0.0,165556.58062994416,0.013608246373622512,610.5501222484049,400000.0,0.001078717403827483,9688.139899813577,299999.99999999994,-0.001078717403827483,165556.58062994416,200000.0,0.0,-146161.29349014745,299999.99999999994,0.0,9688.139899813577,400000.0,0.0,165556.58062994416]},{"time":0.7,"state":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942],"rhs":[0.0009279701322459646,8.990304464047412,0.00015074727158151846,169.59846037964132,-0.001078717403827483,-178.58876484368872],"outputs":[299999.99999999994,-0.0009279701322459646,9688.139899813577,299999.99999999994,-0.00015074727158151846,1125051.608565458,299999.99999999994,0.001078717403827483,9688.139899813577,0.020319124506141158,-14369.869520404944,200000.0,270.0,0.3564758685287922,-707209.0884654927,-146161.29349014745,200000.0,0.0009279701322459646,-146161.29349014745,200000.0,0.0,-146161.29349014745,0.014882327934781673,-683.1483154393907,200000.0,-0.0009279701322459646,9688.139899813577,299999.99999999994,0.0009279701322459646,-146161.29349014745,0.027426500211483972,-16834.288228988866,299999.99999999994,299.99999999999994,0.4811666703769118,-613796.4413680475,9688.139899813577,299999.99999999994,0.00015074727158151846,9688.139899813577,299999.99999999994,0.0,9688.139899813577,0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.001078717403827483,165556.58062994416,400000.0,0.0,165556.58062994416,0.013608246373622512,610.5501222484049,400000.0,0.001078717403827483,9688.139899813577,299999.99999999994,-0.001078717403827483,165556.58062994416,200000.0,0.0,-146161.29349014745,299999.99999999994,0.0,9688.139899813577,400000.0,0.0,165556.58062994416]}]},{"components":[{"name":"node","type":"amesim_p4node2","parameters":{},"overrides":{},"medium":null},{"name":"s0","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":270.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r0","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"s1","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":300000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"s2","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":400000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r2","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"s3","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":360.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r3","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_9","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_10","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_11","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r0","port_1"],["s0","port_1"]],[["node","port_1"],["r0","port_2"]],[["node","port_2"],["s1","port_1"]],[["r2","port_1"],["s2","port_1"]],[["node","port_3"],["r2","port_2"]],[["r3","port_1"],["s3","port_1"]],[["node","port_4"],["r3","port_2"]],[["cap_8","port_1"],["s0","port_2"]],[["cap_9","port_1"],["s1","port_2"]],[["cap_10","port_1"],["s2","port_2"]],[["cap_11","port_1"],["s3","port_2"]]],"stateKeys":["s0.m","s0.U","s1.m","s1.U","s2.m","s2.U","s3.m","s3.U"],"outputKeys":["node.port_1.p","node.port_1.m_flow","node.port_1.h_outflow","node.port_2.p","node.port_2.m_flow","node.port_2.h_outflow","node.port_3.p","node.port_3.m_flow","node.port_3.h_outflow","node.port_4.p","node.port_4.m_flow","node.port_4.h_outflow","s0.m","s0.U","s0.p","s0.T","s0.rho","s0.u","s0.h","s0.port_1.p","s0.port_1.m_flow","s0.port_1.h_outflow","s0.port_2.p","s0.port_2.m_flow","s0.port_2.h_outflow","r0.cm","r0.gasvel","r0.port_1.p","r0.port_1.m_flow","r0.port_1.h_outflow","r0.port_2.p","r0.port_2.m_flow","r0.port_2.h_outflow","s1.m","s1.U","s1.p","s1.T","s1.rho","s1.u","s1.h","s1.port_1.p","s1.port_1.m_flow","s1.port_1.h_outflow","s1.port_2.p","s1.port_2.m_flow","s1.port_2.h_outflow","s2.m","s2.U","s2.p","s2.T","s2.rho","s2.u","s2.h","s2.port_1.p","s2.port_1.m_flow","s2.port_1.h_outflow","s2.port_2.p","s2.port_2.m_flow","s2.port_2.h_outflow","r2.cm","r2.gasvel","r2.port_1.p","r2.port_1.m_flow","r2.port_1.h_outflow","r2.port_2.p","r2.port_2.m_flow","r2.port_2.h_outflow","s3.m","s3.U","s3.p","s3.T","s3.rho","s3.u","s3.h","s3.port_1.p","s3.port_1.m_flow","s3.port_1.h_outflow","s3.port_2.p","s3.port_2.m_flow","s3.port_2.h_outflow","r3.cm","r3.gasvel","r3.port_1.p","r3.port_1.m_flow","r3.port_1.h_outflow","r3.port_2.p","r3.port_2.m_flow","r3.port_2.h_outflow","cap_8.port_1.p","cap_8.port_1.m_flow","cap_8.port_1.h_outflow","cap_9.port_1.p","cap_9.port_1.m_flow","cap_9.port_1.h_outflow","cap_10.port_1.p","cap_10.port_1.m_flow","cap_10.port_1.h_outflow","cap_11.port_1.p","cap_11.port_1.m_flow","cap_11.port_1.h_outflow"],"initialState":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942,0.038081692612076695,-16259.019649126652],"probes":[{"time":0,"state":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942,0.038081692612076695,-16259.019649126652],"rhs":[0.0009279701322459646,8.990304464047412,0.0016232101692258672,642.9069758666459,-0.001078717403827483,-178.58876484368872,-0.0014724628976443487,-473.30851548700457],"outputs":[299999.99999999994,-0.0009279701322459646,9688.139899813577,299999.99999999994,-0.0016232101692258672,396071.3086052545,299999.99999999994,0.001078717403827483,9688.139899813577,299999.99999999994,0.0014724628976443487,9688.139899813577,0.020319124506141158,-14369.869520404944,200000.0,270.0,0.3564758685287922,-707209.0884654927,-146161.29349014745,200000.0,0.0009279701322459646,-146161.29349014745,200000.0,0.0,-146161.29349014745,0.014882327934781673,-683.1483154393907,200000.0,-0.0009279701322459646,9688.139899813577,299999.99999999994,0.0009279701322459646,-146161.29349014745,0.027426500211483972,-16834.288228988866,299999.99999999994,299.99999999999994,0.4811666703769118,-613796.4413680475,9688.139899813577,299999.99999999994,0.0016232101692258672,9688.139899813577,299999.99999999994,0.0,9688.139899813577,0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.001078717403827483,165556.58062994416,400000.0,0.0,165556.58062994416,0.013608246373622512,610.5501222484049,400000.0,0.001078717403827483,9688.139899813577,299999.99999999994,-0.001078717403827483,165556.58062994416,0.038081692612076695,-16259.019649126652,499999.99999999965,359.99999999999983,0.6680998703873104,-426951.0763282222,321440.02829830564,499999.99999999965,-0.0014724628976443487,321440.02829830564,499999.99999999965,0.0,321440.02829830564,0.015521121755491725,831.5265677552956,499999.99999999965,0.0014724628976443487,9688.139899813577,299999.99999999994,-0.0014724628976443487,321440.02829830564,200000.0,0.0,-146161.29349014745,299999.99999999994,0.0,9688.139899813577,400000.0,0.0,165556.58062994416,499999.99999999965,0.0,321440.02829830564]},{"time":0.7,"state":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942,0.038081692612076695,-16259.019649126652],"rhs":[0.0009279701322459646,8.990304464047412,0.0016232101692258672,642.9069758666459,-0.001078717403827483,-178.58876484368872,-0.0014724628976443487,-473.30851548700457],"outputs":[299999.99999999994,-0.0009279701322459646,9688.139899813577,299999.99999999994,-0.0016232101692258672,396071.3086052545,299999.99999999994,0.001078717403827483,9688.139899813577,299999.99999999994,0.0014724628976443487,9688.139899813577,0.020319124506141158,-14369.869520404944,200000.0,270.0,0.3564758685287922,-707209.0884654927,-146161.29349014745,200000.0,0.0009279701322459646,-146161.29349014745,200000.0,0.0,-146161.29349014745,0.014882327934781673,-683.1483154393907,200000.0,-0.0009279701322459646,9688.139899813577,299999.99999999994,0.0009279701322459646,-146161.29349014745,0.027426500211483972,-16834.288228988866,299999.99999999994,299.99999999999994,0.4811666703769118,-613796.4413680475,9688.139899813577,299999.99999999994,0.0016232101692258672,9688.139899813577,299999.99999999994,0.0,9688.139899813577,0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.001078717403827483,165556.58062994416,400000.0,0.0,165556.58062994416,0.013608246373622512,610.5501222484049,400000.0,0.001078717403827483,9688.139899813577,299999.99999999994,-0.001078717403827483,165556.58062994416,0.038081692612076695,-16259.019649126652,499999.99999999965,359.99999999999983,0.6680998703873104,-426951.0763282222,321440.02829830564,499999.99999999965,-0.0014724628976443487,321440.02829830564,499999.99999999965,0.0,321440.02829830564,0.015521121755491725,831.5265677552956,499999.99999999965,0.0014724628976443487,9688.139899813577,299999.99999999994,-0.0014724628976443487,321440.02829830564,200000.0,0.0,-146161.29349014745,299999.99999999994,0.0,9688.139899813577,400000.0,0.0,165556.58062994416,499999.99999999965,0.0,321440.02829830564]}]},{"components":[{"name":"node","type":"tee","parameters":{},"overrides":{},"medium":null},{"name":"s0","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":200000.0,"T0":270.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"s1","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":300000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r1","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"s2","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":400000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r2","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_7","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["node","port_in"],["s0","port_1"]],[["r1","port_1"],["s1","port_1"]],[["node","port_out1"],["r1","port_2"]],[["r2","port_1"],["s2","port_1"]],[["node","port_out2"],["r2","port_2"]],[["cap_6","port_1"],["s0","port_2"]],[["cap_7","port_1"],["s1","port_2"]],[["cap_8","port_1"],["s2","port_2"]]],"stateKeys":["s0.m","s0.U","s1.m","s1.U","s2.m","s2.U"],"outputKeys":["node.port_in.p","node.port_in.m_flow","node.port_in.h_outflow","node.port_out1.p","node.port_out1.m_flow","node.port_out1.h_outflow","node.port_out2.p","node.port_out2.m_flow","node.port_out2.h_outflow","s0.m","s0.U","s0.p","s0.T","s0.rho","s0.u","s0.h","s0.port_1.p","s0.port_1.m_flow","s0.port_1.h_outflow","s0.port_2.p","s0.port_2.m_flow","s0.port_2.h_outflow","s1.m","s1.U","s1.p","s1.T","s1.rho","s1.u","s1.h","s1.port_1.p","s1.port_1.m_flow","s1.port_1.h_outflow","s1.port_2.p","s1.port_2.m_flow","s1.port_2.h_outflow","r1.cm","r1.gasvel","r1.port_1.p","r1.port_1.m_flow","r1.port_1.h_outflow","r1.port_2.p","r1.port_2.m_flow","r1.port_2.h_outflow","s2.m","s2.U","s2.p","s2.T","s2.rho","s2.u","s2.h","s2.port_1.p","s2.port_1.m_flow","s2.port_1.h_outflow","s2.port_2.p","s2.port_2.m_flow","s2.port_2.h_outflow","r2.cm","r2.gasvel","r2.port_1.p","r2.port_1.m_flow","r2.port_1.h_outflow","r2.port_2.p","r2.port_2.m_flow","r2.port_2.h_outflow","cap_6.port_1.p","cap_6.port_1.m_flow","cap_6.port_1.h_outflow","cap_7.port_1.p","cap_7.port_1.m_flow","cap_7.port_1.h_outflow","cap_8.port_1.p","cap_8.port_1.m_flow","cap_8.port_1.h_outflow"],"initialState":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942],"probes":[{"time":0,"state":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942],"rhs":[0.002190483350152074,218.0076758206905,-0.0009279701322459646,-8.990304464047412,-0.0012625132179061097,-209.0173713566431],"outputs":[200000.0,-0.002190483350152074,99524.91800750521,200000.0,0.0009279701322459646,99524.91800750521,200000.0,0.0012625132179061097,99524.91800750521,0.020319124506141158,-14369.869520404944,200000.0,270.0,0.3564758685287922,-707209.0884654927,-146161.29349014745,200000.0,0.002190483350152074,-146161.29349014745,200000.0,0.0,-146161.29349014745,0.027426500211483972,-16834.288228988866,299999.99999999994,299.99999999999994,0.4811666703769118,-613796.4413680475,9688.139899813577,299999.99999999994,-0.0009279701322459646,9688.139899813577,299999.99999999994,0.0,9688.139899813577,0.014882327934781673,683.1483154393907,299999.99999999994,0.0009279701322459646,99524.91800750521,200000.0,-0.0009279701322459646,9688.139899813577,0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.0012625132179061097,165556.58062994416,400000.0,0.0,165556.58062994416,0.01592686913019247,911.2246873616473,400000.0,0.0012625132179061097,99524.91800750521,200000.0,-0.0012625132179061097,165556.58062994416,200000.0,0.0,-146161.29349014745,299999.99999999994,0.0,9688.139899813577,400000.0,0.0,165556.58062994416]},{"time":0.7,"state":[0.020319124506141158,-14369.869520404944,0.027426500211483972,-16834.288228988866,0.03323938980873858,-17297.00028103942],"rhs":[0.002190483350152074,218.0076758206905,-0.0009279701322459646,-8.990304464047412,-0.0012625132179061097,-209.0173713566431],"outputs":[200000.0,-0.002190483350152074,99524.91800750521,200000.0,0.0009279701322459646,99524.91800750521,200000.0,0.0012625132179061097,99524.91800750521,0.020319124506141158,-14369.869520404944,200000.0,270.0,0.3564758685287922,-707209.0884654927,-146161.29349014745,200000.0,0.002190483350152074,-146161.29349014745,200000.0,0.0,-146161.29349014745,0.027426500211483972,-16834.288228988866,299999.99999999994,299.99999999999994,0.4811666703769118,-613796.4413680475,9688.139899813577,299999.99999999994,-0.0009279701322459646,9688.139899813577,299999.99999999994,0.0,9688.139899813577,0.014882327934781673,683.1483154393907,299999.99999999994,0.0009279701322459646,99524.91800750521,200000.0,-0.0009279701322459646,9688.139899813577,0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.0012625132179061097,165556.58062994416,400000.0,0.0,165556.58062994416,0.01592686913019247,911.2246873616473,400000.0,0.0012625132179061097,99524.91800750521,200000.0,-0.0012625132179061097,165556.58062994416,200000.0,0.0,-146161.29349014745,299999.99999999994,0.0,9688.139899813577,400000.0,0.0,165556.58062994416]}]},{"components":[{"name":"mass","type":"amesim_mecmas21","parameters":{"mass":1.0,"fstick":0.0,"fcoul":2.0,"rvisc":3.0,"wind":0.1,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":1.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":2.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"s","type":"amesim_step0","parameters":{"initial":4.0,"final":6.0,"time":0.5},"overrides":{},"medium":null},{"name":"f","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"free","type":"amesim_f000","parameters":{},"overrides":{},"medium":null}],"connections":[[["s","out"],["f","res"]],[["f","port_2"],["mass","port_1"]],[["free","port_1"],["mass","port_2"]]],"stateKeys":["mass.v","mass.x"],"outputKeys":["mass.a","mass.v","mass.x","mass.Fvisc","mass.Ffric","mass.Fmin","mass.Fmax","mass.port_1.x","mass.port_1.v","mass.port_1.f","mass.port_2.x","mass.port_2.v","mass.port_2.f","s.y","s.out.signal","f.force","f.res.signal","f.port_2.x","f.port_2.v","f.port_2.f","free.port_1.x","free.port_1.v","free.port_1.f"],"initialState":[0.0,0.0],"probes":[{"time":0,"state":[0.0,0.0],"rhs":[4.0,0.0],"outputs":[4.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,4.0,0.0,0.0,0.0,4.0,4.0,4.0,4.0,0.0,0.0,-4.0,0.0,0.0,0.0]},{"time":0.7,"state":[0.3,0.0],"rhs":[3.0909999999999993,0.3],"outputs":[3.0909999999999993,0.3,0.0,-0.8999999999999999,-2.0,0.0,0.0,0.0,0.3,6.0,0.0,0.3,0.0,6.0,6.0,6.0,6.0,0.0,0.3,-6.0,0.0,0.3,0.0]},{"time":0.7,"state":[-0.2,-0.9],"rhs":[8.604,-0.2],"outputs":[8.604,-0.2,-0.9,0.6000000000000001,2.0,0.0,0.0,-0.9,-0.2,6.0,-0.9,-0.2,0.0,6.0,6.0,6.0,6.0,-0.9,-0.2,-6.0,-0.9,-0.2,0.0]}]},{"components":[{"name":"mass","type":"amesim_mecmas21","parameters":{"mass":1.0,"fstick":0.0,"fcoul":2.0,"rvisc":3.0,"wind":0.1,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":1.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":2.0,"stoptype":2.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"s","type":"amesim_step0","parameters":{"initial":4.0,"final":6.0,"time":0.5},"overrides":{},"medium":null},{"name":"f","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"free","type":"amesim_f000","parameters":{},"overrides":{},"medium":null}],"connections":[[["s","out"],["f","res"]],[["f","port_2"],["mass","port_1"]],[["free","port_1"],["mass","port_2"]]],"stateKeys":["mass.v","mass.x"],"outputKeys":["mass.a","mass.v","mass.x","mass.Fvisc","mass.Ffric","mass.Fmin","mass.Fmax","mass.port_1.x","mass.port_1.v","mass.port_1.f","mass.port_2.x","mass.port_2.v","mass.port_2.f","s.y","s.out.signal","f.force","f.res.signal","f.port_2.x","f.port_2.v","f.port_2.f","free.port_1.x","free.port_1.v","free.port_1.f"],"initialState":[0.0,0.0],"probes":[{"time":0,"state":[0.0,0.0],"rhs":[4.0,0.0],"outputs":[4.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,4.0,0.0,0.0,0.0,4.0,4.0,4.0,4.0,0.0,0.0,-4.0,0.0,0.0,0.0]},{"time":0.7,"state":[0.3,0.0],"rhs":[3.0909999999999993,0.3],"outputs":[3.0909999999999993,0.3,0.0,-0.8999999999999999,-2.0,0.0,0.0,0.0,0.3,6.0,0.0,0.3,0.0,6.0,6.0,6.0,6.0,0.0,0.3,-6.0,0.0,0.3,0.0]},{"time":0.7,"state":[-0.2,-1.01],"rhs":[10002008.60400001,-0.2],"outputs":[10002008.60400001,-0.2,-1.01,0.6000000000000001,2.0,10002000.00000001,0.0,-1.01,-0.2,6.0,-1.01,-0.2,0.0,6.0,6.0,6.0,6.0,-1.01,-0.2,-6.0,-1.01,-0.2,0.0]}]},{"components":[{"name":"mass","type":"amesim_mecmas21","parameters":{"mass":1.0,"fstick":0.0,"fcoul":2.0,"rvisc":3.0,"wind":0.1,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":1.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":2.0,"stoptype":3.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"s","type":"amesim_step0","parameters":{"initial":4.0,"final":6.0,"time":0.5},"overrides":{},"medium":null},{"name":"f","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"free","type":"amesim_f000","parameters":{},"overrides":{},"medium":null}],"connections":[[["s","out"],["f","res"]],[["f","port_2"],["mass","port_1"]],[["free","port_1"],["mass","port_2"]]],"stateKeys":["mass.v","mass.x"],"outputKeys":["mass.a","mass.v","mass.x","mass.Fvisc","mass.Ffric","mass.Fmin","mass.Fmax","mass.port_1.x","mass.port_1.v","mass.port_1.f","mass.port_2.x","mass.port_2.v","mass.port_2.f","s.y","s.out.signal","f.force","f.res.signal","f.port_2.x","f.port_2.v","f.port_2.f","free.port_1.x","free.port_1.v","free.port_1.f"],"initialState":[0.0,0.0],"probes":[{"time":0,"state":[0.0,0.0],"rhs":[4.0,0.0],"outputs":[4.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,4.0,0.0,0.0,0.0,4.0,4.0,4.0,4.0,0.0,0.0,-4.0,0.0,0.0,0.0]},{"time":0.7,"state":[0.3,0.0],"rhs":[3.0909999999999993,0.3],"outputs":[3.0909999999999993,0.3,0.0,-0.8999999999999999,-2.0,0.0,0.0,0.0,0.3,6.0,0.0,0.3,0.0,6.0,6.0,6.0,6.0,0.0,0.3,-6.0,0.0,0.3,0.0]},{"time":0.7,"state":[-0.2,-0.9],"rhs":[8.604,-0.2],"outputs":[8.604,-0.2,-0.9,0.6000000000000001,2.0,0.0,0.0,-0.9,-0.2,6.0,-0.9,-0.2,0.0,6.0,6.0,6.0,6.0,-0.9,-0.2,-6.0,-0.9,-0.2,0.0]}]},{"components":[{"name":"mass","type":"amesim_mecmas21","parameters":{"mass":1.0,"fstick":0.0,"fcoul":2.0,"rvisc":3.0,"wind":0.1,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":1.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":2.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"s","type":"amesim_step0","parameters":{"initial":4.0,"final":6.0,"time":0.5},"overrides":{},"medium":null},{"name":"f","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"free","type":"amesim_f000","parameters":{},"overrides":{},"medium":null}],"connections":[[["s","out"],["f","res"]],[["f","port_2"],["mass","port_1"]],[["free","port_1"],["mass","port_2"]]],"stateKeys":["mass.v","mass.x"],"outputKeys":["mass.a","mass.v","mass.x","mass.Fvisc","mass.Ffric","mass.Fmin","mass.Fmax","mass.port_1.x","mass.port_1.v","mass.port_1.f","mass.port_2.x","mass.port_2.v","mass.port_2.f","s.y","s.out.signal","f.force","f.res.signal","f.port_2.x","f.port_2.v","f.port_2.f","free.port_1.x","free.port_1.v","free.port_1.f"],"initialState":[0.0,0.0],"probes":[{"time":0,"state":[0.0,0.0],"rhs":[4.0,0.0],"outputs":[4.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,4.0,0.0,0.0,0.0,4.0,4.0,4.0,4.0,0.0,0.0,-4.0,0.0,0.0,0.0]},{"time":0.7,"state":[0.3,0.0],"rhs":[3.0909999999999993,0.3],"outputs":[3.0909999999999993,0.3,0.0,-0.8999999999999999,-2.0,0.0,0.0,0.0,0.3,6.0,0.0,0.3,0.0,6.0,6.0,6.0,6.0,0.0,0.3,-6.0,0.0,0.3,0.0]},{"time":0.7,"state":[-0.2,-0.9],"rhs":[8.604,-0.2],"outputs":[8.604,-0.2,-0.9,0.6000000000000001,2.0,0.0,0.0,-0.9,-0.2,6.0,-0.9,-0.2,0.0,6.0,6.0,6.0,6.0,-0.9,-0.2,-6.0,-0.9,-0.2,0.0]}]},{"components":[{"name":"node","type":"amesim_lmechn1","parameters":{"v1":3.0,"sum":1.0},"overrides":{},"medium":null},{"name":"f0","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"s0","type":"amesim_step0","parameters":{"initial":1.0,"final":2.0,"time":0.0},"overrides":{},"medium":null},{"name":"m","type":"amesim_mecmas21","parameters":{"mass":2.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.8,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":2.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"f1","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"s1","type":"amesim_step0","parameters":{"initial":2.0,"final":3.0,"time":0.0},"overrides":{},"medium":null},{"name":"f2","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"s2","type":"amesim_step0","parameters":{"initial":3.0,"final":4.0,"time":0.0},"overrides":{},"medium":null},{"name":"f3","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"s3","type":"amesim_step0","parameters":{"initial":4.0,"final":5.0,"time":0.0},"overrides":{},"medium":null}],"connections":[[["m","port_1"],["node","port_1"]],[["f0","port_2"],["m","port_2"]],[["f0","res"],["s0","out"]],[["f1","port_2"],["node","port_2"]],[["f1","res"],["s1","out"]],[["f2","port_2"],["node","port_3"]],[["f2","res"],["s2","out"]],[["f3","port_2"],["node","port_4"]],[["f3","res"],["s3","out"]]],"stateKeys":["m.v","m.x"],"outputKeys":["node.tforce","node.port_1.x","node.port_1.v","node.port_1.f","node.port_2.x","node.port_2.v","node.port_2.f","node.port_3.x","node.port_3.v","node.port_3.f","node.port_4.x","node.port_4.v","node.port_4.f","f0.force","f0.res.signal","f0.port_2.x","f0.port_2.v","f0.port_2.f","s0.y","s0.out.signal","m.a","m.v","m.x","m.Fvisc","m.Ffric","m.Fmin","m.Fmax","m.port_1.x","m.port_1.v","m.port_1.f","m.port_2.x","m.port_2.v","m.port_2.f","f1.force","f1.res.signal","f1.port_2.x","f1.port_2.v","f1.port_2.f","s1.y","s1.out.signal","f2.force","f2.res.signal","f2.port_2.x","f2.port_2.v","f2.port_2.f","s2.y","s2.out.signal","f3.force","f3.res.signal","f3.port_2.x","f3.port_2.v","f3.port_2.f","s3.y","s3.out.signal"],"initialState":[0.0,0.0],"probes":[{"time":0,"state":[0.0,0.0],"rhs":[7.0,0.0],"outputs":[-5.0,0.0,0.0,-12.0,0.0,0.0,3.0,0.0,0.0,4.0,0.0,0.0,5.0,2.0,2.0,0.0,0.0,-2.0,2.0,2.0,7.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,12.0,0.0,0.0,2.0,3.0,3.0,0.0,0.0,-3.0,3.0,3.0,4.0,4.0,0.0,0.0,-4.0,4.0,4.0,5.0,5.0,0.0,0.0,-5.0,5.0,5.0]},{"time":0.7,"state":[0.0,0.0],"rhs":[7.0,0.0],"outputs":[-5.0,0.0,0.0,-12.0,0.0,0.0,3.0,0.0,0.0,4.0,0.0,0.0,5.0,2.0,2.0,0.0,0.0,-2.0,2.0,2.0,7.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,12.0,0.0,0.0,2.0,3.0,3.0,0.0,0.0,-3.0,3.0,3.0,4.0,4.0,0.0,0.0,-4.0,4.0,4.0,5.0,5.0,0.0,0.0,-5.0,5.0,5.0]}]},{"components":[{"name":"left","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":400000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"right","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":100000.0,"T0":280.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"a","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"c","type":"amesim_pnl00r","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["a","port_1"],["left","port_1"]],[["a","port_2"],["c","port_1"]],[["c","port_2"],["right","port_1"]],[["cap_4","port_1"],["left","port_2"]],[["cap_5","port_1"],["right","port_2"]]],"stateKeys":["left.m","left.U","right.m","right.U"],"outputKeys":["left.m","left.U","left.p","left.T","left.rho","left.u","left.h","left.port_1.p","left.port_1.m_flow","left.port_1.h_outflow","left.port_2.p","left.port_2.m_flow","left.port_2.h_outflow","right.m","right.U","right.p","right.T","right.rho","right.u","right.h","right.port_1.p","right.port_1.m_flow","right.port_1.h_outflow","right.port_2.p","right.port_2.m_flow","right.port_2.h_outflow","a.cm","a.gasvel","a.port_1.p","a.port_1.m_flow","a.port_1.h_outflow","a.port_2.p","a.port_2.m_flow","a.port_2.h_outflow","c.re","c.cm","c.v","c.ff","c.port_1.p","c.port_1.m_flow","c.port_1.h_outflow","c.port_2.p","c.port_2.m_flow","c.port_2.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow"],"initialState":[0.03323938980873858,-17297.00028103942,0.009798368436796304,-6623.385717335973],"probes":[{"time":0,"state":[0.03323938980873858,-17297.00028103942,0.009798368436796304,-6623.385717335973],"rhs":[-0.001262898954393521,-209.08123257052313,0.0012628989543935502,209.08123257052796],"outputs":[0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.001262898954393521,165556.58062994416,400000.0,0.0,165556.58062994416,0.009798368436796304,-6623.385717335973,100000.0,280.0,0.17190120064554917,-675968.2247161518,-94238.72181294355,100000.0,0.0012628989543935502,-94238.72181294355,100000.0,0.0,-94238.72181294355,0.01593173527691205,926.0895640088154,400000.0,0.001262898954393521,-94238.72181294355,102953.27185399602,-0.001262898954393521,165556.58062994416,7547.957094749814,0.005186478680241379,107.0887668466143,0.03341370740154756,102953.27185399602,0.0012628989543935502,-94238.72181294355,100000.0,-0.0012628989543935502,165556.58062994416,400000.0,0.0,165556.58062994416,100000.0,0.0,-94238.72181294355]},{"time":0.7,"state":[0.03323938980873858,-17297.00028103942,0.009798368436796304,-6623.385717335973],"rhs":[-0.001262898954393521,-209.08123257052313,0.0012628989543935502,209.08123257052796],"outputs":[0.03323938980873858,-17297.00028103942,400000.0,329.99999999999994,0.5831471896269925,-520376.5887571158,165556.58062994416,400000.0,-0.001262898954393521,165556.58062994416,400000.0,0.0,165556.58062994416,0.009798368436796304,-6623.385717335973,100000.0,280.0,0.17190120064554917,-675968.2247161518,-94238.72181294355,100000.0,0.0012628989543935502,-94238.72181294355,100000.0,0.0,-94238.72181294355,0.01593173527691205,926.0895640088154,400000.0,0.001262898954393521,-94238.72181294355,102953.27185399602,-0.001262898954393521,165556.58062994416,7547.957094749814,0.005186478680241379,107.0887668466143,0.03341370740154756,102953.27185399602,0.0012628989543935502,-94238.72181294355,100000.0,-0.0012628989543935502,165556.58062994416,400000.0,0.0,165556.58062994416,100000.0,0.0,-94238.72181294355]}]},{"components":[{"name":"a","type":"amesim_pnl0003","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p1_0":100000.0,"T1_0":293.15,"p2_0":100000.0,"T2_0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"c","type":"amesim_pnl0003","parameters":{"gi":0.0,"diam":0.01,"le":1.0,"rr":1e-05,"k":1.35,"kth":0.0,"extemp":293.15,"mode":2.0,"p1_0":100000.0,"T1_0":293.15,"p2_0":100000.0,"T2_0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"source","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":300000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"valve","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["a","port_2"],["c","port_1"]],[["source","port_1"],["valve","port_1"]],[["a","port_1"],["valve","port_2"]],[["c","port_2"],["cap_4","port_1"]],[["cap_5","port_1"],["source","port_2"]]],"stateKeys":["a.m1","a.U1","a.m2","a.U2","c.m1","c.U1","c.m2","c.U2","source.m","source.U"],"outputKeys":["a.m1","a.U1","a.p1","a.T1","a.rho1","a.u1","a.h1","a.m2","a.U2","a.p2","a.T2","a.rho2","a.u2","a.h2","a.dmctr","a.re","a.cm","a.v","a.ff","a.port_1.p","a.port_1.m_flow","a.port_1.h_outflow","a.port_2.p","a.port_2.m_flow","a.port_2.h_outflow","c.m1","c.U1","c.p1","c.T1","c.rho1","c.u1","c.h1","c.m2","c.U2","c.p2","c.T2","c.rho2","c.u2","c.h2","c.dmctr","c.re","c.cm","c.v","c.ff","c.port_1.p","c.port_1.m_flow","c.port_1.h_outflow","c.port_2.p","c.port_2.m_flow","c.port_2.h_outflow","source.m","source.U","source.p","source.T","source.rho","source.u","source.h","source.port_1.p","source.port_1.m_flow","source.port_1.h_outflow","source.port_2.p","source.port_2.m_flow","source.port_2.h_outflow","valve.cm","valve.gasvel","valve.port_1.p","valve.port_1.m_flow","valve.port_1.h_outflow","valve.port_2.p","valve.port_2.m_flow","valve.port_2.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow"],"initialState":[6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,0.02806728581330189,-17826.7588048152],"probes":[{"time":0,"state":[6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,0.02806728581330189,-17826.7588048152],"rhs":[0.0010049853188466072,-26.02253506235531,0.0,0.0,0.0,0.0,0.0,0.0,-0.0010049853188466072,26.02253506235531],"outputs":[6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,0.0010049853188466072,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,0.0,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,0.02806728581330189,-17826.7588048152,300000.0,293.1499999999999,0.492408523040384,-635143.6659531428,-25893.44796819582,300000.0,-0.0010049853188466072,-25893.44796819582,300000.0,0.0,-25893.44796819582,0.015932389359594415,872.77521795099,300000.0,0.0010049853188466072,-25942.86971068256,99999.99999999999,-0.0010049853188466072,-25893.44796819582,99999.99999999999,0.0,-25942.86971068256,300000.0,0.0,-25893.44796819582]},{"time":0.7,"state":[6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,6.447741534960834e-06,-4.094263735556887,0.02806728581330189,-17826.7588048152],"rhs":[0.0010049853188466072,-26.02253506235531,0.0,0.0,0.0,0.0,0.0,0.0,-0.0010049853188466072,26.02253506235531],"outputs":[6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,0.0010049853188466072,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,6.447741534960834e-06,-4.094263735556887,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,0.0,0.0,0.0,0.0,64000000.0,99999.99999999999,0.0,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,0.02806728581330189,-17826.7588048152,300000.0,293.1499999999999,0.492408523040384,-635143.6659531428,-25893.44796819582,300000.0,-0.0010049853188466072,-25893.44796819582,300000.0,0.0,-25893.44796819582,0.015932389359594415,872.77521795099,300000.0,0.0010049853188466072,-25942.86971068256,99999.99999999999,-0.0010049853188466072,-25893.44796819582,99999.99999999999,0.0,-25942.86971068256,300000.0,0.0,-25893.44796819582]}]},{"components":[{"name":"target","type":"cylinder","parameters":{"volume":0.01,"p0":35000000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_b"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.port_b.p","target.port_b.m_flow","target.port_b.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow"],"initialState":[0.561636096978513,-344604.1666666667,0.041542497856081516,-21620.656859538725],"probes":[{"time":0,"state":[0.561636096978513,-344604.1666666667,0.041542497856081516,-21620.656859538725],"rhs":[-0.12987921826367427,-15792.903438909305,0.12987921826367427,15792.903438909305],"outputs":[0.561636096978513,-344604.1666666667,41289734.52518736,307.18062350351613,56.1636096978513,-613571.9703925129,121596.8470556032,41289734.52518736,-0.12987921826367427,121596.8470556032,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,0.12987921826367427,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.015314117798315086,975.5472486957489,41289734.52518736,0.12987921826367427,165597.72511257746,499999.99999999965,-0.12987921826367427,121596.8470556032,499999.99999999965,0.0,165597.72511257746]},{"time":0.7,"state":[0.561636096978513,-344604.1666666667,0.041542497856081516,-21620.656859538725],"rhs":[-0.12987921826367427,-15792.903438909305,0.12987921826367427,15792.903438909305],"outputs":[0.561636096978513,-344604.1666666667,41289734.52518736,307.18062350351613,56.1636096978513,-613571.9703925129,121596.8470556032,41289734.52518736,-0.12987921826367427,121596.8470556032,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,0.12987921826367427,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.015314117798315086,975.5472486957489,41289734.52518736,0.12987921826367427,165597.72511257746,499999.99999999965,-0.12987921826367427,121596.8470556032,499999.99999999965,0.0,165597.72511257746]}]},{"components":[{"name":"target","type":"cylinder","parameters":{"volume":0.01,"p0":35000000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_b"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.port_b.p","target.port_b.m_flow","target.port_b.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow"],"initialState":[4.065040650406504,875609.7560975611,0.30091859360152046,71299.65156794425],"probes":[{"time":0,"state":[4.065040650406504,875609.7560975611,0.30091859360152046,71299.65156794425],"rhs":[-0.2940081754270385,-88643.46489125211,0.2940081754270385,88643.46489125211],"outputs":[4.065040650406504,875609.7560975611,35000000.0,300.0,406.50406504065046,215400.0,301500.0,35000000.0,-0.2940081754270385,301500.0,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,0.2940081754270385,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,316.92534058227676,35000000.0,0.2940081754270385,331650.0,500000.00000000006,-0.2940081754270385,301500.0,500000.00000000006,0.0,331650.0]},{"time":0.7,"state":[4.065040650406504,875609.7560975611,0.30091859360152046,71299.65156794425],"rhs":[-0.2940081754270385,-88643.46489125211,0.2940081754270385,88643.46489125211],"outputs":[4.065040650406504,875609.7560975611,35000000.0,300.0,406.50406504065046,215400.0,301500.0,35000000.0,-0.2940081754270385,301500.0,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,0.2940081754270385,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,316.92534058227676,35000000.0,0.2940081754270385,331650.0,500000.00000000006,-0.2940081754270385,301500.0,500000.00000000006,0.0,331650.0]}]},{"components":[{"name":"target","type":"tank","parameters":{"volume":0.1,"p0":100000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_a"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.port_a.p","target.port_a.m_flow","target.port_a.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow"],"initialState":[0.016046745627957514,-9845.833333333332,0.041542497856081516,-21620.656859538725],"probes":[{"time":0,"state":[0.016046745627957514,-9845.833333333332,0.041542497856081516,-21620.656859538725],"rhs":[0.0015785668584755018,261.4070807016511,-0.0015785668584755018,-261.4070807016511],"outputs":[0.016046745627957514,-9845.833333333332,100024.1848034332,300.0240543605136,0.16046745627957512,-613571.9703925129,9758.062515628086,100024.1848034332,0.0015785668584755018,9758.062515628086,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,-0.0015785668584755018,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.015931161693433032,-926.1884961079049,100024.1848034332,-0.0015785668584755018,165597.72511257746,499999.99999999965,0.0015785668584755018,9758.062515628086,499999.99999999965,0.0,165597.72511257746]},{"time":0.7,"state":[0.016046745627957514,-9845.833333333332,0.041542497856081516,-21620.656859538725],"rhs":[0.0015785668584755018,261.4070807016511,-0.0015785668584755018,-261.4070807016511],"outputs":[0.016046745627957514,-9845.833333333332,100024.1848034332,300.0240543605136,0.16046745627957512,-613571.9703925129,9758.062515628086,100024.1848034332,0.0015785668584755018,9758.062515628086,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,-0.0015785668584755018,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.015931161693433032,-926.1884961079049,100024.1848034332,-0.0015785668584755018,165597.72511257746,499999.99999999965,0.0015785668584755018,9758.062515628086,499999.99999999965,0.0,165597.72511257746]}]},{"components":[{"name":"target","type":"tank","parameters":{"volume":0.1,"p0":100000.0,"T0":300.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_a"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.port_a.p","target.port_a.m_flow","target.port_a.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow"],"initialState":[0.11614401858304298,25017.421602787457,0.30091859360152046,71299.65156794425],"probes":[{"time":0,"state":[0.11614401858304298,25017.421602787457,0.30091859360152046,71299.65156794425],"rhs":[0.0040046542314576645,1328.1435758629345,-0.0040046542314576645,-1328.1435758629345],"outputs":[0.11614401858304298,25017.421602787457,100000.0,300.0,1.1614401858304297,215400.0,301500.0,100000.0,0.0040046542314576645,301500.0,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,-0.0040046542314576645,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,-332.39410141203064,100000.0,-0.0040046542314576645,331650.0,500000.00000000006,0.0040046542314576645,301500.0,500000.00000000006,0.0,331650.0]},{"time":0.7,"state":[0.11614401858304298,25017.421602787457,0.30091859360152046,71299.65156794425],"rhs":[0.0040046542314576645,1328.1435758629345,-0.0040046542314576645,-1328.1435758629345],"outputs":[0.11614401858304298,25017.421602787457,100000.0,300.0,1.1614401858304297,215400.0,301500.0,100000.0,0.0040046542314576645,301500.0,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,-0.0040046542314576645,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,-332.39410141203064,100000.0,-0.0040046542314576645,331650.0,500000.00000000006,0.0040046542314576645,301500.0,500000.00000000006,0.0,331650.0]}]},{"components":[{"name":"target","type":"amesim_pnch012","parameters":{"gi":0.0,"cvol0":0.015,"kth":7.0,"sth":0.5,"extemp":350.0,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_1"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["target","port_2"]],[["cap_4","port_1"],["target","port_3"]],[["cap_5","port_1"],["target","port_4"]],[["cap_6","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.vol","target.dvol","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","target.port_4.p","target.port_4.m_flow","target.port_4.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow","cap_6.port_1.p","cap_6.port_1.m_flow","cap_6.port_1.h_outflow"],"initialState":[0.002462855848963059,-1563.893548405841,0.041542497856081516,-21620.656859538725],"probes":[{"time":0,"state":[0.002462855848963059,-1563.893548405841,0.041542497856081516,-21620.656859538725],"rhs":[0.0015785668397641929,460.382077603101,-0.0015785668397641929,-261.4070776031009],"outputs":[0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0015785668397641929,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,-0.0015785668397641929,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.01593116150459536,-926.1884898492785,100000.0,-0.0015785668397641929,165597.72511257746,499999.99999999965,0.0015785668397641929,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,499999.99999999965,0.0,165597.72511257746]},{"time":0.7,"state":[0.002462855848963059,-1563.893548405841,0.041542497856081516,-21620.656859538725],"rhs":[0.0015785668397641929,460.382077603101,-0.0015785668397641929,-261.4070776031009],"outputs":[0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0015785668397641929,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,-0.0015785668397641929,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.01593116150459536,-926.1884898492785,100000.0,-0.0015785668397641929,165597.72511257746,499999.99999999965,0.0015785668397641929,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,499999.99999999965,0.0,165597.72511257746]}]},{"components":[{"name":"target","type":"amesim_pnch012","parameters":{"gi":0.0,"cvol0":0.015,"kth":7.0,"sth":0.5,"extemp":350.0,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_1"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["target","port_2"]],[["cap_4","port_1"],["target","port_3"]],[["cap_5","port_1"],["target","port_4"]],[["cap_6","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.vol","target.dvol","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","target.port_3.p","target.port_3.m_flow","target.port_3.h_outflow","target.port_4.p","target.port_4.m_flow","target.port_4.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow","cap_5.port_1.p","cap_5.port_1.m_flow","cap_5.port_1.h_outflow","cap_6.port_1.p","cap_6.port_1.m_flow","cap_6.port_1.h_outflow"],"initialState":[0.017828691237376546,3752.6132404181185,0.30091859360152046,71299.65156794425],"probes":[{"time":0,"state":[0.017828691237376546,3752.6132404181185,0.30091859360152046,71299.65156794425],"rhs":[0.0040046542314576645,1527.1185758629347,-0.0040046542314576645,-1328.1435758629345],"outputs":[0.017828691237376546,3752.6132404181185,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,0.015,0.0,100000.0,0.0040046542314576645,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,-0.0040046542314576645,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,-332.39410141203064,100000.0,-0.0040046542314576645,331650.0,500000.00000000006,0.0040046542314576645,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,500000.00000000006,0.0,331650.0]},{"time":0.7,"state":[0.017828691237376546,3752.6132404181185,0.30091859360152046,71299.65156794425],"rhs":[0.0040046542314576645,1527.1185758629347,-0.0040046542314576645,-1328.1435758629345],"outputs":[0.017828691237376546,3752.6132404181185,100000.0,293.15,1.188579415825103,210481.69999999998,294615.75,0.015,0.0,100000.0,0.0040046542314576645,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,-0.0040046542314576645,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,-332.39410141203064,100000.0,-0.0040046542314576645,331650.0,500000.00000000006,0.0040046542314576645,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,100000.0,0.0,294615.75,500000.00000000006,0.0,331650.0]}]},{"components":[{"name":"target","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":7.0,"sth":0.5,"extemp":350.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_1"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["target","port_2"]],[["cap_4","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.009358852226059625,-5942.795483942196,0.041542497856081516,-21620.656859538725],"probes":[{"time":0,"state":[0.009358852226059625,-5942.795483942196,0.041542497856081516,-21620.656859538725],"rhs":[0.0015785668397641924,460.3820776031009,-0.0015785668397641924,-261.40707760310084],"outputs":[0.009358852226059625,-5942.795483942196,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,99999.99999999999,0.0015785668397641924,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,-0.0015785668397641924,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.015931161504595358,-926.1884898492784,99999.99999999999,-0.0015785668397641924,165597.72511257746,499999.99999999965,0.0015785668397641924,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,499999.99999999965,0.0,165597.72511257746]},{"time":0.7,"state":[0.009358852226059625,-5942.795483942196,0.041542497856081516,-21620.656859538725],"rhs":[0.0015785668397641924,460.3820776031009,-0.0015785668397641924,-261.40707760310084],"outputs":[0.009358852226059625,-5942.795483942196,99999.99999999999,293.15,0.1641903899308706,-634991.9135804437,-25942.86971068256,99999.99999999999,0.0015785668397641924,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,0.041542497856081516,-21620.656859538725,499999.99999999965,329.9999999999998,0.7288157518610792,-520446.7226414894,165597.72511257746,499999.99999999965,-0.0015785668397641924,165597.72511257746,499999.99999999965,0.0,165597.72511257746,0.015931161504595358,-926.1884898492784,99999.99999999999,-0.0015785668397641924,165597.72511257746,499999.99999999965,0.0015785668397641924,-25942.86971068256,99999.99999999999,0.0,-25942.86971068256,499999.99999999965,0.0,165597.72511257746]}]},{"components":[{"name":"target","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":7.0,"sth":0.5,"extemp":350.0,"p0":100000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"other","type":"amesim_pnch023","parameters":{"gi":0.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":500000.0,"T0":330.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"r","type":"amesim_pnor001","parameters":{"gi":0.0,"cq":0.72,"area":5e-06,"Cv":0.5,"Kv":0.4,"flowset":1.0},"overrides":{},"medium":{"type":"AmesimIdealAirMedium","parameters":{"name":"AMESimAirIdealGas","R_gas":287.0,"cp_ref":1005.0,"T_ref":300.0,"cp_slope":0.0,"viscosity_ref":1.82e-05,"viscosity_T_ref":293.15,"sutherland_constant":110.4}}},{"name":"cap_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"cap_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null}],"connections":[[["r","port_1"],["target","port_1"]],[["other","port_1"],["r","port_2"]],[["cap_3","port_1"],["target","port_2"]],[["cap_4","port_1"],["other","port_2"]]],"stateKeys":["target.m","target.U","other.m","other.U"],"outputKeys":["target.m","target.U","target.p","target.T","target.rho","target.u","target.h","target.port_1.p","target.port_1.m_flow","target.port_1.h_outflow","target.port_2.p","target.port_2.m_flow","target.port_2.h_outflow","other.m","other.U","other.p","other.T","other.rho","other.u","other.h","other.port_1.p","other.port_1.m_flow","other.port_1.h_outflow","other.port_2.p","other.port_2.m_flow","other.port_2.h_outflow","r.cm","r.gasvel","r.port_1.p","r.port_1.m_flow","r.port_1.h_outflow","r.port_2.p","r.port_2.m_flow","r.port_2.h_outflow","cap_3.port_1.p","cap_3.port_1.m_flow","cap_3.port_1.h_outflow","cap_4.port_1.p","cap_4.port_1.m_flow","cap_4.port_1.h_outflow"],"initialState":[0.06774902670203088,14259.930313588853,0.30091859360152046,71299.65156794425],"probes":[{"time":0,"state":[0.06774902670203088,14259.930313588853,0.30091859360152046,71299.65156794425],"rhs":[0.0040046542314576645,1527.1185758629347,-0.0040046542314576645,-1328.1435758629345],"outputs":[0.06774902670203088,14259.930313588853,100000.00000000001,293.15,1.188579415825103,210481.69999999998,294615.75,100000.00000000001,0.0040046542314576645,294615.75,100000.00000000001,0.0,294615.75,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,-0.0040046542314576645,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,-332.39410141203064,100000.00000000001,-0.0040046542314576645,331650.0,500000.00000000006,0.0040046542314576645,294615.75,100000.00000000001,0.0,294615.75,500000.00000000006,0.0,331650.0]},{"time":0.7,"state":[0.06774902670203088,14259.930313588853,0.30091859360152046,71299.65156794425],"rhs":[0.0040046542314576645,1527.1185758629347,-0.0040046542314576645,-1328.1435758629345],"outputs":[0.06774902670203088,14259.930313588853,100000.00000000001,293.15,1.188579415825103,210481.69999999998,294615.75,100000.00000000001,0.0040046542314576645,294615.75,100000.00000000001,0.0,294615.75,0.30091859360152046,71299.65156794425,500000.00000000006,330.0,5.279273571956499,236940.0,331650.0,500000.00000000006,-0.0040046542314576645,331650.0,500000.00000000006,0.0,331650.0,0.040415642673036045,-332.39410141203064,100000.00000000001,-0.0040046542314576645,331650.0,500000.00000000006,0.0040046542314576645,294615.75,100000.00000000001,0.0,294615.75,500000.00000000006,0.0,331650.0]}]},{"components":[{"name":"amesim_pnch023_1","type":"amesim_pnch023","parameters":{"gi":1.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":15300000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnvo001_1","type":"amesim_pnvo001","parameters":{"gi":1.0,"cq":0.45,"area0":7.85e-05,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_step0_1","type":"amesim_step0","parameters":{"initial":0.0,"final":1.0,"time":0.04},"overrides":{},"medium":null},{"name":"amesim_pnch012_1","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_2","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_ud00_1","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1e+17,"end1":1e+17,"t1":0.8,"start2":49000.0,"end2":49000.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"amesim_forc_1","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"amesim_pnrp17_1","type":"amesim_pnrp17","parameters":{"gi":1.0,"dp":0.2,"dr":0.001,"x0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnch012_2","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_7","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_9","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_mecmas21_2","type":"amesim_mecmas21","parameters":{"mass":50.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.8,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"amesim_f000_1","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_f000_2","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_lstp00a_1","type":"amesim_lstp00a","parameters":{"na":10.0,"gap0":0.0,"kcont":100000000000.0,"G":85700000000.0,"sdiam":0.02,"wdiam":0.002,"rcont":100000000000.0,"Pdis":1e-07,"stiffmode":1.0,"discContactOption":1.0},"overrides":{},"medium":null},{"name":"amesim_forc_2","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"amesim_ud00_2","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1000000000000.0,"end1":1000000000000.0,"t1":0.8,"start2":0.0,"end2":0.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"amesim_mecmas21_5","type":"amesim_mecmas21","parameters":{"mass":170000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":0.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.37,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"amesim_mecmas21_7","type":"amesim_mecmas21","parameters":{"mass":90000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-0.72,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null}],"connections":[[["amesim_pnch023_1","port_2"],["amesim_pnvo001_1","port_2"]],[["amesim_pnch012_1","port_1"],["amesim_pnvo001_1","port_3"]],[["amesim_pnch012_1","port_2"],["amesim_pnpl01_2","port_1"]],[["amesim_pnch012_1","port_3"],["amesim_pnpl01_3","port_1"]],[["amesim_pnch012_1","port_4"],["amesim_pnpl01_4","port_1"]],[["amesim_step0_1","out"],["amesim_pnvo001_1","res"]],[["amesim_ud00_1","out"],["amesim_forc_1","res"]],[["amesim_pnch023_1","port_1"],["amesim_pnpl01_6","port_1"]],[["amesim_pnch012_2","port_1"],["amesim_pnpl01_7","port_1"]],[["amesim_pnch012_2","port_4"],["amesim_pnpl01_8","port_1"]],[["amesim_pnch012_2","port_2"],["amesim_pnpl01_9","port_1"]],[["amesim_mecmas21_2","port_1"],["amesim_pnrp17_1","port_2"]],[["amesim_lstp00a_1","port_1"],["amesim_pnrp17_1","port_5"]],[["amesim_ud00_2","out"],["amesim_forc_2","res"]],[["amesim_pnch012_2","port_3"],["amesim_pnrp17_1","port_1"]],[["amesim_forc_1","port_2"],["amesim_mecmas21_7","port_2"]],[["amesim_mecmas21_7","port_1"],["amesim_pnrp17_1","port_3"]],[["amesim_forc_2","port_2"],["amesim_mecmas21_5","port_1"]],[["amesim_lstp00a_1","port_2"],["amesim_mecmas21_5","port_2"]],[["amesim_f000_2","port_1"],["amesim_pnrp17_1","port_4"]],[["amesim_f000_1","port_1"],["amesim_mecmas21_2","port_2"]]],"stateKeys":["amesim_pnch023_1.m","amesim_pnch023_1.U","amesim_pnch012_1.m","amesim_pnch012_1.U","amesim_pnch012_2.m","amesim_pnch012_2.U","amesim_mecmas21_2.v","amesim_mecmas21_2.x","amesim_mecmas21_5.v","amesim_mecmas21_5.x","amesim_mecmas21_7.v","amesim_mecmas21_7.x"],"outputKeys":["amesim_pnch023_1.m","amesim_pnch023_1.U","amesim_pnch023_1.p","amesim_pnch023_1.T","amesim_pnch023_1.rho","amesim_pnch023_1.u","amesim_pnch023_1.h","amesim_pnch023_1.port_1.p","amesim_pnch023_1.port_1.m_flow","amesim_pnch023_1.port_1.h_outflow","amesim_pnch023_1.port_2.p","amesim_pnch023_1.port_2.m_flow","amesim_pnch023_1.port_2.h_outflow","amesim_pnvo001_1.xv","amesim_pnvo001_1.cm","amesim_pnvo001_1.gasvel","amesim_pnvo001_1.res.signal","amesim_pnvo001_1.port_2.p","amesim_pnvo001_1.port_2.m_flow","amesim_pnvo001_1.port_2.h_outflow","amesim_pnvo001_1.port_3.p","amesim_pnvo001_1.port_3.m_flow","amesim_pnvo001_1.port_3.h_outflow","amesim_step0_1.y","amesim_step0_1.out.signal","amesim_pnch012_1.m","amesim_pnch012_1.U","amesim_pnch012_1.p","amesim_pnch012_1.T","amesim_pnch012_1.rho","amesim_pnch012_1.u","amesim_pnch012_1.h","amesim_pnch012_1.vol","amesim_pnch012_1.dvol","amesim_pnch012_1.port_1.p","amesim_pnch012_1.port_1.m_flow","amesim_pnch012_1.port_1.h_outflow","amesim_pnch012_1.port_2.p","amesim_pnch012_1.port_2.m_flow","amesim_pnch012_1.port_2.h_outflow","amesim_pnch012_1.port_3.p","amesim_pnch012_1.port_3.m_flow","amesim_pnch012_1.port_3.h_outflow","amesim_pnch012_1.port_4.p","amesim_pnch012_1.port_4.m_flow","amesim_pnch012_1.port_4.h_outflow","amesim_pnpl01_2.port_1.p","amesim_pnpl01_2.port_1.m_flow","amesim_pnpl01_2.port_1.h_outflow","amesim_pnpl01_3.port_1.p","amesim_pnpl01_3.port_1.m_flow","amesim_pnpl01_3.port_1.h_outflow","amesim_pnpl01_4.port_1.p","amesim_pnpl01_4.port_1.m_flow","amesim_pnpl01_4.port_1.h_outflow","amesim_ud00_1.y","amesim_ud00_1.out.signal","amesim_forc_1.force","amesim_forc_1.res.signal","amesim_forc_1.port_2.x","amesim_forc_1.port_2.v","amesim_forc_1.port_2.f","amesim_pnrp17_1.volume","amesim_pnrp17_1.volume_flow","amesim_pnrp17_1.length","amesim_pnrp17_1.pressure_force","amesim_pnrp17_1.port_1.p","amesim_pnrp17_1.port_1.m_flow","amesim_pnrp17_1.port_1.h_outflow","amesim_pnrp17_1.port_2.x","amesim_pnrp17_1.port_2.v","amesim_pnrp17_1.port_2.f","amesim_pnrp17_1.port_3.x","amesim_pnrp17_1.port_3.v","amesim_pnrp17_1.port_3.f","amesim_pnrp17_1.port_4.x","amesim_pnrp17_1.port_4.v","amesim_pnrp17_1.port_4.f","amesim_pnrp17_1.port_5.x","amesim_pnrp17_1.port_5.v","amesim_pnrp17_1.port_5.f","amesim_pnch012_2.m","amesim_pnch012_2.U","amesim_pnch012_2.p","amesim_pnch012_2.T","amesim_pnch012_2.rho","amesim_pnch012_2.u","amesim_pnch012_2.h","amesim_pnch012_2.vol","amesim_pnch012_2.dvol","amesim_pnch012_2.port_1.p","amesim_pnch012_2.port_1.m_flow","amesim_pnch012_2.port_1.h_outflow","amesim_pnch012_2.port_2.p","amesim_pnch012_2.port_2.m_flow","amesim_pnch012_2.port_2.h_outflow","amesim_pnch012_2.port_3.p","amesim_pnch012_2.port_3.m_flow","amesim_pnch012_2.port_3.h_outflow","amesim_pnch012_2.port_4.p","amesim_pnch012_2.port_4.m_flow","amesim_pnch012_2.port_4.h_outflow","amesim_pnpl01_6.port_1.p","amesim_pnpl01_6.port_1.m_flow","amesim_pnpl01_6.port_1.h_outflow","amesim_pnpl01_7.port_1.p","amesim_pnpl01_7.port_1.m_flow","amesim_pnpl01_7.port_1.h_outflow","amesim_pnpl01_8.port_1.p","amesim_pnpl01_8.port_1.m_flow","amesim_pnpl01_8.port_1.h_outflow","amesim_pnpl01_9.port_1.p","amesim_pnpl01_9.port_1.m_flow","amesim_pnpl01_9.port_1.h_outflow","amesim_mecmas21_2.a","amesim_mecmas21_2.v","amesim_mecmas21_2.x","amesim_mecmas21_2.Fvisc","amesim_mecmas21_2.Ffric","amesim_mecmas21_2.Fmin","amesim_mecmas21_2.Fmax","amesim_mecmas21_2.port_1.x","amesim_mecmas21_2.port_1.v","amesim_mecmas21_2.port_1.f","amesim_mecmas21_2.port_2.x","amesim_mecmas21_2.port_2.v","amesim_mecmas21_2.port_2.f","amesim_f000_1.port_1.x","amesim_f000_1.port_1.v","amesim_f000_1.port_1.f","amesim_f000_2.port_1.x","amesim_f000_2.port_1.v","amesim_f000_2.port_1.f","amesim_lstp00a_1.gap","amesim_lstp00a_1.penetration","amesim_lstp00a_1.force","amesim_lstp00a_1.port_1.x","amesim_lstp00a_1.port_1.v","amesim_lstp00a_1.port_1.f","amesim_lstp00a_1.port_2.x","amesim_lstp00a_1.port_2.v","amesim_lstp00a_1.port_2.f","amesim_forc_2.force","amesim_forc_2.res.signal","amesim_forc_2.port_2.x","amesim_forc_2.port_2.v","amesim_forc_2.port_2.f","amesim_ud00_2.y","amesim_ud00_2.out.signal","amesim_mecmas21_5.a","amesim_mecmas21_5.v","amesim_mecmas21_5.x","amesim_mecmas21_5.Fvisc","amesim_mecmas21_5.Ffric","amesim_mecmas21_5.Fmin","amesim_mecmas21_5.Fmax","amesim_mecmas21_5.port_1.x","amesim_mecmas21_5.port_1.v","amesim_mecmas21_5.port_1.f","amesim_mecmas21_5.port_2.x","amesim_mecmas21_5.port_2.v","amesim_mecmas21_5.port_2.f","amesim_mecmas21_7.a","amesim_mecmas21_7.v","amesim_mecmas21_7.x","amesim_mecmas21_7.Fvisc","amesim_mecmas21_7.Ffric","amesim_mecmas21_7.Fmin","amesim_mecmas21_7.Fmax","amesim_mecmas21_7.port_1.x","amesim_mecmas21_7.port_1.v","amesim_mecmas21_7.port_1.f","amesim_mecmas21_7.port_2.x","amesim_mecmas21_7.port_2.v","amesim_mecmas21_7.port_2.f"],"initialState":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"probes":[{"time":0,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[0.0,0.0,0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,5882352.94117647,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,0.0,0.015770552842049427,0.0,0.0,15299999.999999808,0.0,-25942.86971068256,100000.0,0.0,-10744.47227494699,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1e+17,1e+17,1e+17,1e+17,0.0,0.0,-1e+17,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,1000000000000.0,1000000000000.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17]},{"time":0.04,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,5882352.94117647,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1e+17,1e+17,1e+17,1e+17,0.0,0.0,-1e+17,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,1000000000000.0,1000000000000.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17]},{"time":0.8,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,49000.0]},{"time":1,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,49000.0]},{"time":1,"state":[0.0013745108503351983,-886.868393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[0.0032872105081618113,-85.27967392482849,-0.0032872105081618113,85.27967392482849,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0],"outputs":[0.0013745108503351983,-886.868393723014,14519.152899877528,289.845095989135,0.024114225444477163,-645224.7310428548,-43125.65333008446,14519.152899877528,0.0,-43125.65333008446,14519.152899877528,0.0032872105081618113,-43125.65333008446,1.0,0.015932733623066388,-872.5597063965729,1.0,14519.152899877528,-0.0032872105081618113,-25942.86971068256,100000.0,0.0032872105081618113,-43125.65333008446,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,-0.0032872105081618113,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,14519.152899877528,0.0,-43125.65333008446,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,49000.0]},{"time":1,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,-0.02,0.37000004999999997,0.0,0.37,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,46.58068575414885,15738645.353426127,-0.02,-4629.021648561676,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.011623603791718836,-0.0006283028227546908,0.37000004999999997,-1412.5841784776082,56334.92496550105,0.0,-26008.353885226727,0.37000004999999997,-0.02,-786932267.6713064,0.0,0.0,-1412.5841784776082,0.0,0.0,0.0,0.37000004999999997,-0.02,786933680.2554848,0.002462855848963059,-1563.893548405841,56334.92496550105,293.13934733963936,0.09250647914649032,-634991.9135804437,-26008.353885226727,0.026623603791718835,-0.0006283028227546908,56334.92496550105,0.0,-26008.353885226727,56334.92496550105,0.0,-26008.353885226727,56334.92496550105,0.0,-26008.353885226727,56334.92496550105,0.0,-26008.353885226727,15299999.999999808,0.0,-10744.47227494699,56334.92496550105,0.0,-26008.353885226727,56334.92496550105,0.0,-26008.353885226727,56334.92496550105,0.0,-26008.353885226727,15738645.353426127,-0.02,0.37000004999999997,0.0,0.0,0.0,0.0,0.37000004999999997,-0.02,786932267.6713064,0.37000004999999997,-0.02,0.0,0.37000004999999997,-0.02,0.0,0.0,0.0,0.0,-4.999999997368221e-08,4.999999997368221e-08,-786933680.2554848,0.37000004999999997,-0.02,-786933680.2554848,0.37,0.0,786933680.2554848,0.0,0.0,0.37,0.0,0.0,0.0,0.0,-4629.021648561676,0.0,0.37,0.0,0.0,0.0,0.0,0.37,0.0,0.0,0.37,0.0,-786933680.2554848,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1412.5841784776082,0.0,0.0,49000.0]},{"time":1,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,-0.005,-0.1],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,-8.551169506133991,-11.699151493088085,0.0,0.0,0.0,0.5509439730517156,-0.005],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,-0.1,-0.005,-49000.0,0.003141514113773454,0.0001570757056886727,0.1,-584.9575746544042,82679.75659285586,0.0,-25968.867516189293,0.0,0.0,584.9575746544042,-0.1,-0.005,-584.9575746544042,-0.1,-0.005,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,82679.75659285586,293.14577541751726,0.13575800969629115,-634991.9135804437,-25968.867516189293,0.018141514113773453,0.0001570757056886727,82679.75659285586,0.0,-25968.867516189293,82679.75659285586,0.0,-25968.867516189293,82679.75659285586,0.0,-25968.867516189293,82679.75659285586,0.0,-25968.867516189293,15299999.999999808,0.0,-10744.47227494699,82679.75659285586,0.0,-25968.867516189293,82679.75659285586,0.0,-25968.867516189293,82679.75659285586,0.0,-25968.867516189293,-11.699151493088085,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-584.9575746544042,0.0,0.0,0.0,0.0,0.0,0.0,-0.1,-0.005,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.5509439730517156,-0.005,-0.1,0.0,0.0,0.0,0.0,-0.1,-0.005,584.9575746544042,-0.1,-0.005,49000.0]},{"time":1,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,-0.001,0.0,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,-0.001,0.0,0.0,0.0,0.0,-0.001,0.0,0.0,0.0,0.0,-0.001,0.0,0.0,0.0,0.0,0.0,0.0,-0.001,0.0,0.0,-0.001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,49000.0]}]},{"components":[{"name":"renamed_23","type":"amesim_mecmas21","parameters":{"mass":90000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-0.72,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"renamed_22","type":"amesim_mecmas21","parameters":{"mass":170000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":0.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.37,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"renamed_21","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1000000000000.0,"end1":1000000000000.0,"t1":0.8,"start2":0.0,"end2":0.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"renamed_20","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"renamed_19","type":"amesim_lstp00a","parameters":{"na":10.0,"gap0":0.0,"kcont":100000000000.0,"G":85700000000.0,"sdiam":0.02,"wdiam":0.002,"rcont":100000000000.0,"Pdis":1e-07,"stiffmode":1.0,"discContactOption":1.0},"overrides":{},"medium":null},{"name":"renamed_18","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"renamed_17","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"renamed_16","type":"amesim_mecmas21","parameters":{"mass":50.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.8,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"renamed_15","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"renamed_14","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"renamed_13","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"renamed_12","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"renamed_11","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"renamed_9","type":"amesim_pnrp17","parameters":{"gi":1.0,"dp":0.2,"dr":0.001,"x0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"renamed_8","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"renamed_7","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1e+17,"end1":1e+17,"t1":0.8,"start2":49000.0,"end2":49000.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"renamed_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"renamed_5","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"renamed_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"renamed_3","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"renamed_2","type":"amesim_step0","parameters":{"initial":0.0,"final":1.0,"time":0.07},"overrides":{},"medium":null},{"name":"renamed_1","type":"amesim_pnvo001","parameters":{"gi":1.0,"cq":0.45,"area0":7.85e-05,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"renamed_0","type":"amesim_pnch023","parameters":{"gi":1.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":15300000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}}],"connections":[[["renamed_0","port_2"],["renamed_1","port_2"]],[["renamed_1","port_3"],["renamed_3","port_1"]],[["renamed_3","port_2"],["renamed_4","port_1"]],[["renamed_3","port_3"],["renamed_5","port_1"]],[["renamed_3","port_4"],["renamed_6","port_1"]],[["renamed_1","res"],["renamed_2","out"]],[["renamed_8","res"],["renamed_7","out"]],[["renamed_0","port_1"],["renamed_12","port_1"]],[["renamed_11","port_1"],["renamed_13","port_1"]],[["renamed_11","port_4"],["renamed_14","port_1"]],[["renamed_11","port_2"],["renamed_15","port_1"]],[["renamed_16","port_1"],["renamed_9","port_2"]],[["renamed_19","port_1"],["renamed_9","port_5"]],[["renamed_20","res"],["renamed_21","out"]],[["renamed_11","port_3"],["renamed_9","port_1"]],[["renamed_23","port_2"],["renamed_8","port_2"]],[["renamed_23","port_1"],["renamed_9","port_3"]],[["renamed_20","port_2"],["renamed_22","port_1"]],[["renamed_19","port_2"],["renamed_22","port_2"]],[["renamed_18","port_1"],["renamed_9","port_4"]],[["renamed_16","port_2"],["renamed_17","port_1"]]],"stateKeys":["renamed_23.v","renamed_23.x","renamed_22.v","renamed_22.x","renamed_16.v","renamed_16.x","renamed_11.m","renamed_11.U","renamed_3.m","renamed_3.U","renamed_0.m","renamed_0.U"],"outputKeys":["renamed_23.a","renamed_23.v","renamed_23.x","renamed_23.Fvisc","renamed_23.Ffric","renamed_23.Fmin","renamed_23.Fmax","renamed_23.port_1.x","renamed_23.port_1.v","renamed_23.port_1.f","renamed_23.port_2.x","renamed_23.port_2.v","renamed_23.port_2.f","renamed_22.a","renamed_22.v","renamed_22.x","renamed_22.Fvisc","renamed_22.Ffric","renamed_22.Fmin","renamed_22.Fmax","renamed_22.port_1.x","renamed_22.port_1.v","renamed_22.port_1.f","renamed_22.port_2.x","renamed_22.port_2.v","renamed_22.port_2.f","renamed_21.y","renamed_21.out.signal","renamed_20.force","renamed_20.res.signal","renamed_20.port_2.x","renamed_20.port_2.v","renamed_20.port_2.f","renamed_19.gap","renamed_19.penetration","renamed_19.force","renamed_19.port_1.x","renamed_19.port_1.v","renamed_19.port_1.f","renamed_19.port_2.x","renamed_19.port_2.v","renamed_19.port_2.f","renamed_18.port_1.x","renamed_18.port_1.v","renamed_18.port_1.f","renamed_17.port_1.x","renamed_17.port_1.v","renamed_17.port_1.f","renamed_16.a","renamed_16.v","renamed_16.x","renamed_16.Fvisc","renamed_16.Ffric","renamed_16.Fmin","renamed_16.Fmax","renamed_16.port_1.x","renamed_16.port_1.v","renamed_16.port_1.f","renamed_16.port_2.x","renamed_16.port_2.v","renamed_16.port_2.f","renamed_15.port_1.p","renamed_15.port_1.m_flow","renamed_15.port_1.h_outflow","renamed_14.port_1.p","renamed_14.port_1.m_flow","renamed_14.port_1.h_outflow","renamed_13.port_1.p","renamed_13.port_1.m_flow","renamed_13.port_1.h_outflow","renamed_12.port_1.p","renamed_12.port_1.m_flow","renamed_12.port_1.h_outflow","renamed_11.m","renamed_11.U","renamed_11.p","renamed_11.T","renamed_11.rho","renamed_11.u","renamed_11.h","renamed_11.vol","renamed_11.dvol","renamed_11.port_1.p","renamed_11.port_1.m_flow","renamed_11.port_1.h_outflow","renamed_11.port_2.p","renamed_11.port_2.m_flow","renamed_11.port_2.h_outflow","renamed_11.port_3.p","renamed_11.port_3.m_flow","renamed_11.port_3.h_outflow","renamed_11.port_4.p","renamed_11.port_4.m_flow","renamed_11.port_4.h_outflow","renamed_9.volume","renamed_9.volume_flow","renamed_9.length","renamed_9.pressure_force","renamed_9.port_1.p","renamed_9.port_1.m_flow","renamed_9.port_1.h_outflow","renamed_9.port_2.x","renamed_9.port_2.v","renamed_9.port_2.f","renamed_9.port_3.x","renamed_9.port_3.v","renamed_9.port_3.f","renamed_9.port_4.x","renamed_9.port_4.v","renamed_9.port_4.f","renamed_9.port_5.x","renamed_9.port_5.v","renamed_9.port_5.f","renamed_8.force","renamed_8.res.signal","renamed_8.port_2.x","renamed_8.port_2.v","renamed_8.port_2.f","renamed_7.y","renamed_7.out.signal","renamed_6.port_1.p","renamed_6.port_1.m_flow","renamed_6.port_1.h_outflow","renamed_5.port_1.p","renamed_5.port_1.m_flow","renamed_5.port_1.h_outflow","renamed_4.port_1.p","renamed_4.port_1.m_flow","renamed_4.port_1.h_outflow","renamed_3.m","renamed_3.U","renamed_3.p","renamed_3.T","renamed_3.rho","renamed_3.u","renamed_3.h","renamed_3.vol","renamed_3.dvol","renamed_3.port_1.p","renamed_3.port_1.m_flow","renamed_3.port_1.h_outflow","renamed_3.port_2.p","renamed_3.port_2.m_flow","renamed_3.port_2.h_outflow","renamed_3.port_3.p","renamed_3.port_3.m_flow","renamed_3.port_3.h_outflow","renamed_3.port_4.p","renamed_3.port_4.m_flow","renamed_3.port_4.h_outflow","renamed_2.y","renamed_2.out.signal","renamed_1.xv","renamed_1.cm","renamed_1.gasvel","renamed_1.res.signal","renamed_1.port_2.p","renamed_1.port_2.m_flow","renamed_1.port_2.h_outflow","renamed_1.port_3.p","renamed_1.port_3.m_flow","renamed_1.port_3.h_outflow","renamed_0.m","renamed_0.U","renamed_0.p","renamed_0.T","renamed_0.rho","renamed_0.u","renamed_0.h","renamed_0.port_1.p","renamed_0.port_1.m_flow","renamed_0.port_1.h_outflow","renamed_0.port_2.p","renamed_0.port_2.m_flow","renamed_0.port_2.h_outflow"],"initialState":[0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,1.3745108503351984,-886868.393723014],"probes":[{"time":0.05,"state":[0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,1.3745108503351984,-886868.393723014],"rhs":[0.0,0.0,5882352.94117647,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0],"outputs":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,1e+17,1e+17,0.0,0.0,-1e+17,1e+17,1e+17,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.0,0.0,0.0,0.015770552842049427,0.0,0.0,15299999.999999808,0.0,-25942.86971068256,100000.0,0.0,-10744.47227494699,1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699]},{"time":0.07,"state":[0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,1.3745108503351984,-886868.393723014],"rhs":[0.0,0.0,5882352.94117647,0.0,-0.8167936695810981,0.0,0.0,0.0,0.4978237019458235,-5348.852963368374,-0.4978237019458235,5348.852963368374],"outputs":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,1e+17,1e+17,0.0,0.0,-1e+17,1e+17,1e+17,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1.0,1.0,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699]},{"time":1,"state":[0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,1.3745108503351984,-886868.393723014],"rhs":[0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.4978237019458235,-5348.852963368374,-0.4978237019458235,5348.852963368374],"outputs":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,49000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,49000.0,49000.0,0.0,0.0,-49000.0,49000.0,49000.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1.0,1.0,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699]}]},{"components":[{"name":"amesim_pnch023_1","type":"amesim_pnch023","parameters":{"gi":1.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":15300000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnvo001_1","type":"amesim_pnvo001","parameters":{"gi":1.0,"cq":0.45,"area0":7.85e-05,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_step0_1","type":"amesim_step0","parameters":{"initial":0.0,"final":1.0,"time":0.04},"overrides":{},"medium":null},{"name":"amesim_pnch012_1","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_2","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_ud00_1","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1e+17,"end1":1e+17,"t1":0.8,"start2":49000.0,"end2":49000.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"amesim_forc_1","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"amesim_pnrp17_1","type":"amesim_pnrp17","parameters":{"gi":1.0,"dp":0.2,"dr":0.001,"x0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnch012_2","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"amesim_pnpl01_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_7","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_pnpl01_9","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"amesim_mecmas21_2","type":"amesim_mecmas21","parameters":{"mass":50.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.8,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"amesim_f000_1","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_f000_2","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"amesim_lstp00a_1","type":"amesim_lstp00a","parameters":{"na":10.0,"gap0":0.0,"kcont":100000000000.0,"G":85700000000.0,"sdiam":0.02,"wdiam":0.002,"rcont":100000000000.0,"Pdis":1e-07,"stiffmode":1.0,"discContactOption":1.0},"overrides":{},"medium":null},{"name":"amesim_forc_2","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"amesim_ud00_2","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1000000000000.0,"end1":1000000000000.0,"t1":0.8,"start2":0.0,"end2":0.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"amesim_mecmas21_5","type":"amesim_mecmas21","parameters":{"mass":170000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":0.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.37,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"amesim_mecmas21_7","type":"amesim_mecmas21","parameters":{"mass":90000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-0.72,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"branch2_amesim_pnch023_1","type":"amesim_pnch023","parameters":{"gi":1.0,"cvol":0.057,"kth":0.0,"sth":0.1,"extemp":293.15,"p0":15300000.0,"T0":293.15},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"branch2_amesim_pnvo001_1","type":"amesim_pnvo001","parameters":{"gi":1.0,"cq":0.45,"area0":7.85e-05,"Cv":0.5,"Kv":0.4,"flowset":1.0,"opening0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"branch2_amesim_step0_1","type":"amesim_step0","parameters":{"initial":0.0,"final":1.0,"time":0.04},"overrides":{},"medium":null},{"name":"branch2_amesim_pnch012_1","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"branch2_amesim_pnpl01_2","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_pnpl01_3","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_pnpl01_4","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_ud00_1","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1e+17,"end1":1e+17,"t1":0.8,"start2":49000.0,"end2":49000.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1e+17,49000.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"branch2_amesim_forc_1","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"branch2_amesim_pnrp17_1","type":"amesim_pnrp17","parameters":{"gi":1.0,"dp":0.2,"dr":0.001,"x0":0.0},"overrides":{},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"branch2_amesim_pnch012_2","type":"amesim_pnch012","parameters":{"gi":1.0,"cvol0":0.015,"kth":1500.0,"sth":0.7,"extemp":293.15,"p0":100000.0,"T0":293.15,"vol1":0.0,"vol2":0.0,"vol3":0.0,"vol4":0.0,"dvol1":0.0,"dvol2":0.0,"dvol3":0.0,"dvol4":0.0},"overrides":{"external_volumes":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0},"external_volume_rates":{"port_1":0.0,"port_2":0.0,"port_3":0.0,"port_4":0.0}},"medium":{"type":"AmesimHeliumPengRobinsonMedium","parameters":{"name":"AMESimHeliumPengRobinson","R_gas":2077.26439404998,"cp_ref":5193.1609851249505,"T_ref":293.15,"cp_slope":0.0,"viscosity_ref":1.96e-05,"viscosity_T_ref":293.15,"sutherland_constant":79.4}}},{"name":"branch2_amesim_pnpl01_6","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_pnpl01_7","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_pnpl01_8","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_pnpl01_9","type":"amesim_pnpl01","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_mecmas21_2","type":"amesim_mecmas21","parameters":{"mass":50.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-1.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.8,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":4.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"branch2_amesim_f000_1","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_f000_2","type":"amesim_f000","parameters":{},"overrides":{},"medium":null},{"name":"branch2_amesim_lstp00a_1","type":"amesim_lstp00a","parameters":{"na":10.0,"gap0":0.0,"kcont":100000000000.0,"G":85700000000.0,"sdiam":0.02,"wdiam":0.002,"rcont":100000000000.0,"Pdis":1e-07,"stiffmode":1.0,"discContactOption":1.0},"overrides":{},"medium":null},{"name":"branch2_amesim_forc_2","type":"amesim_forc","parameters":{"direction":1.0},"overrides":{},"medium":null},{"name":"branch2_amesim_ud00_2","type":"amesim_ud00","parameters":{"tstart":0.0,"start1":1000000000000.0,"end1":1000000000000.0,"t1":0.8,"start2":0.0,"end2":0.0,"t2":10.0,"start3":1.0,"end3":1.0,"t3":0.0,"start4":1.0,"end4":1.0,"t4":0.0,"start5":1.0,"end5":1.0,"t5":0.0,"start6":1.0,"end6":1.0,"t6":0.0,"start7":1.0,"end7":1.0,"t7":0.0,"start8":1.0,"end8":1.0,"t8":0.0,"nstages":2.0,"iscyclic":0.0},"overrides":{"starts":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"ends":[1000000000000.0,0.0,1.0,1.0,1.0,1.0,1.0,1.0],"durations":[0.8,10.0,0.0,0.0,0.0,0.0,0.0,0.0],"nstages":2,"iscyclic":false},"medium":null},{"name":"branch2_amesim_mecmas21_5","type":"amesim_mecmas21","parameters":{"mass":170000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":0.0,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.37,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null},{"name":"branch2_amesim_mecmas21_7","type":"amesim_mecmas21","parameters":{"mass":90000.0,"fstick":0.0,"fcoul":0.0,"rvisc":0.0,"wind":0.0,"dvel":1e-06,"restdvel":1e-06,"restcoeff":0.65,"astrib":0.001,"xmin":-0.72,"Kbmin":1000000000.0,"Dbmin":10000.0,"Pdmin":0.0001,"xmax":0.0,"Kbmax":1000000000.0,"Dbmax":10000.0,"Pdmax":0.0001,"theta":0.0,"useFriction":1.0,"stoptype":1.0,"discContactOption":1.0,"strib":1.0,"frictionType":1.0,"v0":0.0,"x0":0.0},"overrides":{},"medium":null}],"connections":[[["amesim_pnch023_1","port_2"],["amesim_pnvo001_1","port_2"]],[["amesim_pnch012_1","port_1"],["amesim_pnvo001_1","port_3"]],[["amesim_pnch012_1","port_2"],["amesim_pnpl01_2","port_1"]],[["amesim_pnch012_1","port_3"],["amesim_pnpl01_3","port_1"]],[["amesim_pnch012_1","port_4"],["amesim_pnpl01_4","port_1"]],[["amesim_step0_1","out"],["amesim_pnvo001_1","res"]],[["amesim_ud00_1","out"],["amesim_forc_1","res"]],[["amesim_pnch023_1","port_1"],["amesim_pnpl01_6","port_1"]],[["amesim_pnch012_2","port_1"],["amesim_pnpl01_7","port_1"]],[["amesim_pnch012_2","port_4"],["amesim_pnpl01_8","port_1"]],[["amesim_pnch012_2","port_2"],["amesim_pnpl01_9","port_1"]],[["amesim_mecmas21_2","port_1"],["amesim_pnrp17_1","port_2"]],[["amesim_lstp00a_1","port_1"],["amesim_pnrp17_1","port_5"]],[["amesim_ud00_2","out"],["amesim_forc_2","res"]],[["amesim_pnch012_2","port_3"],["amesim_pnrp17_1","port_1"]],[["amesim_forc_1","port_2"],["amesim_mecmas21_7","port_2"]],[["amesim_mecmas21_7","port_1"],["amesim_pnrp17_1","port_3"]],[["amesim_forc_2","port_2"],["amesim_mecmas21_5","port_1"]],[["amesim_lstp00a_1","port_2"],["amesim_mecmas21_5","port_2"]],[["amesim_f000_2","port_1"],["amesim_pnrp17_1","port_4"]],[["amesim_f000_1","port_1"],["amesim_mecmas21_2","port_2"]],[["branch2_amesim_pnch023_1","port_2"],["branch2_amesim_pnvo001_1","port_2"]],[["branch2_amesim_pnch012_1","port_1"],["branch2_amesim_pnvo001_1","port_3"]],[["branch2_amesim_pnch012_1","port_2"],["branch2_amesim_pnpl01_2","port_1"]],[["branch2_amesim_pnch012_1","port_3"],["branch2_amesim_pnpl01_3","port_1"]],[["branch2_amesim_pnch012_1","port_4"],["branch2_amesim_pnpl01_4","port_1"]],[["branch2_amesim_step0_1","out"],["branch2_amesim_pnvo001_1","res"]],[["branch2_amesim_ud00_1","out"],["branch2_amesim_forc_1","res"]],[["branch2_amesim_pnch023_1","port_1"],["branch2_amesim_pnpl01_6","port_1"]],[["branch2_amesim_pnch012_2","port_1"],["branch2_amesim_pnpl01_7","port_1"]],[["branch2_amesim_pnch012_2","port_4"],["branch2_amesim_pnpl01_8","port_1"]],[["branch2_amesim_pnch012_2","port_2"],["branch2_amesim_pnpl01_9","port_1"]],[["branch2_amesim_mecmas21_2","port_1"],["branch2_amesim_pnrp17_1","port_2"]],[["branch2_amesim_lstp00a_1","port_1"],["branch2_amesim_pnrp17_1","port_5"]],[["branch2_amesim_ud00_2","out"],["branch2_amesim_forc_2","res"]],[["branch2_amesim_pnch012_2","port_3"],["branch2_amesim_pnrp17_1","port_1"]],[["branch2_amesim_forc_1","port_2"],["branch2_amesim_mecmas21_7","port_2"]],[["branch2_amesim_mecmas21_7","port_1"],["branch2_amesim_pnrp17_1","port_3"]],[["branch2_amesim_forc_2","port_2"],["branch2_amesim_mecmas21_5","port_1"]],[["branch2_amesim_lstp00a_1","port_2"],["branch2_amesim_mecmas21_5","port_2"]],[["branch2_amesim_f000_2","port_1"],["branch2_amesim_pnrp17_1","port_4"]],[["branch2_amesim_f000_1","port_1"],["branch2_amesim_mecmas21_2","port_2"]]],"stateKeys":["amesim_pnch023_1.m","amesim_pnch023_1.U","amesim_pnch012_1.m","amesim_pnch012_1.U","amesim_pnch012_2.m","amesim_pnch012_2.U","amesim_mecmas21_2.v","amesim_mecmas21_2.x","amesim_mecmas21_5.v","amesim_mecmas21_5.x","amesim_mecmas21_7.v","amesim_mecmas21_7.x","branch2_amesim_pnch023_1.m","branch2_amesim_pnch023_1.U","branch2_amesim_pnch012_1.m","branch2_amesim_pnch012_1.U","branch2_amesim_pnch012_2.m","branch2_amesim_pnch012_2.U","branch2_amesim_mecmas21_2.v","branch2_amesim_mecmas21_2.x","branch2_amesim_mecmas21_5.v","branch2_amesim_mecmas21_5.x","branch2_amesim_mecmas21_7.v","branch2_amesim_mecmas21_7.x"],"outputKeys":["amesim_pnch023_1.m","amesim_pnch023_1.U","amesim_pnch023_1.p","amesim_pnch023_1.T","amesim_pnch023_1.rho","amesim_pnch023_1.u","amesim_pnch023_1.h","amesim_pnch023_1.port_1.p","amesim_pnch023_1.port_1.m_flow","amesim_pnch023_1.port_1.h_outflow","amesim_pnch023_1.port_2.p","amesim_pnch023_1.port_2.m_flow","amesim_pnch023_1.port_2.h_outflow","amesim_pnvo001_1.xv","amesim_pnvo001_1.cm","amesim_pnvo001_1.gasvel","amesim_pnvo001_1.res.signal","amesim_pnvo001_1.port_2.p","amesim_pnvo001_1.port_2.m_flow","amesim_pnvo001_1.port_2.h_outflow","amesim_pnvo001_1.port_3.p","amesim_pnvo001_1.port_3.m_flow","amesim_pnvo001_1.port_3.h_outflow","amesim_step0_1.y","amesim_step0_1.out.signal","amesim_pnch012_1.m","amesim_pnch012_1.U","amesim_pnch012_1.p","amesim_pnch012_1.T","amesim_pnch012_1.rho","amesim_pnch012_1.u","amesim_pnch012_1.h","amesim_pnch012_1.vol","amesim_pnch012_1.dvol","amesim_pnch012_1.port_1.p","amesim_pnch012_1.port_1.m_flow","amesim_pnch012_1.port_1.h_outflow","amesim_pnch012_1.port_2.p","amesim_pnch012_1.port_2.m_flow","amesim_pnch012_1.port_2.h_outflow","amesim_pnch012_1.port_3.p","amesim_pnch012_1.port_3.m_flow","amesim_pnch012_1.port_3.h_outflow","amesim_pnch012_1.port_4.p","amesim_pnch012_1.port_4.m_flow","amesim_pnch012_1.port_4.h_outflow","amesim_pnpl01_2.port_1.p","amesim_pnpl01_2.port_1.m_flow","amesim_pnpl01_2.port_1.h_outflow","amesim_pnpl01_3.port_1.p","amesim_pnpl01_3.port_1.m_flow","amesim_pnpl01_3.port_1.h_outflow","amesim_pnpl01_4.port_1.p","amesim_pnpl01_4.port_1.m_flow","amesim_pnpl01_4.port_1.h_outflow","amesim_ud00_1.y","amesim_ud00_1.out.signal","amesim_forc_1.force","amesim_forc_1.res.signal","amesim_forc_1.port_2.x","amesim_forc_1.port_2.v","amesim_forc_1.port_2.f","amesim_pnrp17_1.volume","amesim_pnrp17_1.volume_flow","amesim_pnrp17_1.length","amesim_pnrp17_1.pressure_force","amesim_pnrp17_1.port_1.p","amesim_pnrp17_1.port_1.m_flow","amesim_pnrp17_1.port_1.h_outflow","amesim_pnrp17_1.port_2.x","amesim_pnrp17_1.port_2.v","amesim_pnrp17_1.port_2.f","amesim_pnrp17_1.port_3.x","amesim_pnrp17_1.port_3.v","amesim_pnrp17_1.port_3.f","amesim_pnrp17_1.port_4.x","amesim_pnrp17_1.port_4.v","amesim_pnrp17_1.port_4.f","amesim_pnrp17_1.port_5.x","amesim_pnrp17_1.port_5.v","amesim_pnrp17_1.port_5.f","amesim_pnch012_2.m","amesim_pnch012_2.U","amesim_pnch012_2.p","amesim_pnch012_2.T","amesim_pnch012_2.rho","amesim_pnch012_2.u","amesim_pnch012_2.h","amesim_pnch012_2.vol","amesim_pnch012_2.dvol","amesim_pnch012_2.port_1.p","amesim_pnch012_2.port_1.m_flow","amesim_pnch012_2.port_1.h_outflow","amesim_pnch012_2.port_2.p","amesim_pnch012_2.port_2.m_flow","amesim_pnch012_2.port_2.h_outflow","amesim_pnch012_2.port_3.p","amesim_pnch012_2.port_3.m_flow","amesim_pnch012_2.port_3.h_outflow","amesim_pnch012_2.port_4.p","amesim_pnch012_2.port_4.m_flow","amesim_pnch012_2.port_4.h_outflow","amesim_pnpl01_6.port_1.p","amesim_pnpl01_6.port_1.m_flow","amesim_pnpl01_6.port_1.h_outflow","amesim_pnpl01_7.port_1.p","amesim_pnpl01_7.port_1.m_flow","amesim_pnpl01_7.port_1.h_outflow","amesim_pnpl01_8.port_1.p","amesim_pnpl01_8.port_1.m_flow","amesim_pnpl01_8.port_1.h_outflow","amesim_pnpl01_9.port_1.p","amesim_pnpl01_9.port_1.m_flow","amesim_pnpl01_9.port_1.h_outflow","amesim_mecmas21_2.a","amesim_mecmas21_2.v","amesim_mecmas21_2.x","amesim_mecmas21_2.Fvisc","amesim_mecmas21_2.Ffric","amesim_mecmas21_2.Fmin","amesim_mecmas21_2.Fmax","amesim_mecmas21_2.port_1.x","amesim_mecmas21_2.port_1.v","amesim_mecmas21_2.port_1.f","amesim_mecmas21_2.port_2.x","amesim_mecmas21_2.port_2.v","amesim_mecmas21_2.port_2.f","amesim_f000_1.port_1.x","amesim_f000_1.port_1.v","amesim_f000_1.port_1.f","amesim_f000_2.port_1.x","amesim_f000_2.port_1.v","amesim_f000_2.port_1.f","amesim_lstp00a_1.gap","amesim_lstp00a_1.penetration","amesim_lstp00a_1.force","amesim_lstp00a_1.port_1.x","amesim_lstp00a_1.port_1.v","amesim_lstp00a_1.port_1.f","amesim_lstp00a_1.port_2.x","amesim_lstp00a_1.port_2.v","amesim_lstp00a_1.port_2.f","amesim_forc_2.force","amesim_forc_2.res.signal","amesim_forc_2.port_2.x","amesim_forc_2.port_2.v","amesim_forc_2.port_2.f","amesim_ud00_2.y","amesim_ud00_2.out.signal","amesim_mecmas21_5.a","amesim_mecmas21_5.v","amesim_mecmas21_5.x","amesim_mecmas21_5.Fvisc","amesim_mecmas21_5.Ffric","amesim_mecmas21_5.Fmin","amesim_mecmas21_5.Fmax","amesim_mecmas21_5.port_1.x","amesim_mecmas21_5.port_1.v","amesim_mecmas21_5.port_1.f","amesim_mecmas21_5.port_2.x","amesim_mecmas21_5.port_2.v","amesim_mecmas21_5.port_2.f","amesim_mecmas21_7.a","amesim_mecmas21_7.v","amesim_mecmas21_7.x","amesim_mecmas21_7.Fvisc","amesim_mecmas21_7.Ffric","amesim_mecmas21_7.Fmin","amesim_mecmas21_7.Fmax","amesim_mecmas21_7.port_1.x","amesim_mecmas21_7.port_1.v","amesim_mecmas21_7.port_1.f","amesim_mecmas21_7.port_2.x","amesim_mecmas21_7.port_2.v","amesim_mecmas21_7.port_2.f","branch2_amesim_pnch023_1.m","branch2_amesim_pnch023_1.U","branch2_amesim_pnch023_1.p","branch2_amesim_pnch023_1.T","branch2_amesim_pnch023_1.rho","branch2_amesim_pnch023_1.u","branch2_amesim_pnch023_1.h","branch2_amesim_pnch023_1.port_1.p","branch2_amesim_pnch023_1.port_1.m_flow","branch2_amesim_pnch023_1.port_1.h_outflow","branch2_amesim_pnch023_1.port_2.p","branch2_amesim_pnch023_1.port_2.m_flow","branch2_amesim_pnch023_1.port_2.h_outflow","branch2_amesim_pnvo001_1.xv","branch2_amesim_pnvo001_1.cm","branch2_amesim_pnvo001_1.gasvel","branch2_amesim_pnvo001_1.res.signal","branch2_amesim_pnvo001_1.port_2.p","branch2_amesim_pnvo001_1.port_2.m_flow","branch2_amesim_pnvo001_1.port_2.h_outflow","branch2_amesim_pnvo001_1.port_3.p","branch2_amesim_pnvo001_1.port_3.m_flow","branch2_amesim_pnvo001_1.port_3.h_outflow","branch2_amesim_step0_1.y","branch2_amesim_step0_1.out.signal","branch2_amesim_pnch012_1.m","branch2_amesim_pnch012_1.U","branch2_amesim_pnch012_1.p","branch2_amesim_pnch012_1.T","branch2_amesim_pnch012_1.rho","branch2_amesim_pnch012_1.u","branch2_amesim_pnch012_1.h","branch2_amesim_pnch012_1.vol","branch2_amesim_pnch012_1.dvol","branch2_amesim_pnch012_1.port_1.p","branch2_amesim_pnch012_1.port_1.m_flow","branch2_amesim_pnch012_1.port_1.h_outflow","branch2_amesim_pnch012_1.port_2.p","branch2_amesim_pnch012_1.port_2.m_flow","branch2_amesim_pnch012_1.port_2.h_outflow","branch2_amesim_pnch012_1.port_3.p","branch2_amesim_pnch012_1.port_3.m_flow","branch2_amesim_pnch012_1.port_3.h_outflow","branch2_amesim_pnch012_1.port_4.p","branch2_amesim_pnch012_1.port_4.m_flow","branch2_amesim_pnch012_1.port_4.h_outflow","branch2_amesim_pnpl01_2.port_1.p","branch2_amesim_pnpl01_2.port_1.m_flow","branch2_amesim_pnpl01_2.port_1.h_outflow","branch2_amesim_pnpl01_3.port_1.p","branch2_amesim_pnpl01_3.port_1.m_flow","branch2_amesim_pnpl01_3.port_1.h_outflow","branch2_amesim_pnpl01_4.port_1.p","branch2_amesim_pnpl01_4.port_1.m_flow","branch2_amesim_pnpl01_4.port_1.h_outflow","branch2_amesim_ud00_1.y","branch2_amesim_ud00_1.out.signal","branch2_amesim_forc_1.force","branch2_amesim_forc_1.res.signal","branch2_amesim_forc_1.port_2.x","branch2_amesim_forc_1.port_2.v","branch2_amesim_forc_1.port_2.f","branch2_amesim_pnrp17_1.volume","branch2_amesim_pnrp17_1.volume_flow","branch2_amesim_pnrp17_1.length","branch2_amesim_pnrp17_1.pressure_force","branch2_amesim_pnrp17_1.port_1.p","branch2_amesim_pnrp17_1.port_1.m_flow","branch2_amesim_pnrp17_1.port_1.h_outflow","branch2_amesim_pnrp17_1.port_2.x","branch2_amesim_pnrp17_1.port_2.v","branch2_amesim_pnrp17_1.port_2.f","branch2_amesim_pnrp17_1.port_3.x","branch2_amesim_pnrp17_1.port_3.v","branch2_amesim_pnrp17_1.port_3.f","branch2_amesim_pnrp17_1.port_4.x","branch2_amesim_pnrp17_1.port_4.v","branch2_amesim_pnrp17_1.port_4.f","branch2_amesim_pnrp17_1.port_5.x","branch2_amesim_pnrp17_1.port_5.v","branch2_amesim_pnrp17_1.port_5.f","branch2_amesim_pnch012_2.m","branch2_amesim_pnch012_2.U","branch2_amesim_pnch012_2.p","branch2_amesim_pnch012_2.T","branch2_amesim_pnch012_2.rho","branch2_amesim_pnch012_2.u","branch2_amesim_pnch012_2.h","branch2_amesim_pnch012_2.vol","branch2_amesim_pnch012_2.dvol","branch2_amesim_pnch012_2.port_1.p","branch2_amesim_pnch012_2.port_1.m_flow","branch2_amesim_pnch012_2.port_1.h_outflow","branch2_amesim_pnch012_2.port_2.p","branch2_amesim_pnch012_2.port_2.m_flow","branch2_amesim_pnch012_2.port_2.h_outflow","branch2_amesim_pnch012_2.port_3.p","branch2_amesim_pnch012_2.port_3.m_flow","branch2_amesim_pnch012_2.port_3.h_outflow","branch2_amesim_pnch012_2.port_4.p","branch2_amesim_pnch012_2.port_4.m_flow","branch2_amesim_pnch012_2.port_4.h_outflow","branch2_amesim_pnpl01_6.port_1.p","branch2_amesim_pnpl01_6.port_1.m_flow","branch2_amesim_pnpl01_6.port_1.h_outflow","branch2_amesim_pnpl01_7.port_1.p","branch2_amesim_pnpl01_7.port_1.m_flow","branch2_amesim_pnpl01_7.port_1.h_outflow","branch2_amesim_pnpl01_8.port_1.p","branch2_amesim_pnpl01_8.port_1.m_flow","branch2_amesim_pnpl01_8.port_1.h_outflow","branch2_amesim_pnpl01_9.port_1.p","branch2_amesim_pnpl01_9.port_1.m_flow","branch2_amesim_pnpl01_9.port_1.h_outflow","branch2_amesim_mecmas21_2.a","branch2_amesim_mecmas21_2.v","branch2_amesim_mecmas21_2.x","branch2_amesim_mecmas21_2.Fvisc","branch2_amesim_mecmas21_2.Ffric","branch2_amesim_mecmas21_2.Fmin","branch2_amesim_mecmas21_2.Fmax","branch2_amesim_mecmas21_2.port_1.x","branch2_amesim_mecmas21_2.port_1.v","branch2_amesim_mecmas21_2.port_1.f","branch2_amesim_mecmas21_2.port_2.x","branch2_amesim_mecmas21_2.port_2.v","branch2_amesim_mecmas21_2.port_2.f","branch2_amesim_f000_1.port_1.x","branch2_amesim_f000_1.port_1.v","branch2_amesim_f000_1.port_1.f","branch2_amesim_f000_2.port_1.x","branch2_amesim_f000_2.port_1.v","branch2_amesim_f000_2.port_1.f","branch2_amesim_lstp00a_1.gap","branch2_amesim_lstp00a_1.penetration","branch2_amesim_lstp00a_1.force","branch2_amesim_lstp00a_1.port_1.x","branch2_amesim_lstp00a_1.port_1.v","branch2_amesim_lstp00a_1.port_1.f","branch2_amesim_lstp00a_1.port_2.x","branch2_amesim_lstp00a_1.port_2.v","branch2_amesim_lstp00a_1.port_2.f","branch2_amesim_forc_2.force","branch2_amesim_forc_2.res.signal","branch2_amesim_forc_2.port_2.x","branch2_amesim_forc_2.port_2.v","branch2_amesim_forc_2.port_2.f","branch2_amesim_ud00_2.y","branch2_amesim_ud00_2.out.signal","branch2_amesim_mecmas21_5.a","branch2_amesim_mecmas21_5.v","branch2_amesim_mecmas21_5.x","branch2_amesim_mecmas21_5.Fvisc","branch2_amesim_mecmas21_5.Ffric","branch2_amesim_mecmas21_5.Fmin","branch2_amesim_mecmas21_5.Fmax","branch2_amesim_mecmas21_5.port_1.x","branch2_amesim_mecmas21_5.port_1.v","branch2_amesim_mecmas21_5.port_1.f","branch2_amesim_mecmas21_5.port_2.x","branch2_amesim_mecmas21_5.port_2.v","branch2_amesim_mecmas21_5.port_2.f","branch2_amesim_mecmas21_7.a","branch2_amesim_mecmas21_7.v","branch2_amesim_mecmas21_7.x","branch2_amesim_mecmas21_7.Fvisc","branch2_amesim_mecmas21_7.Ffric","branch2_amesim_mecmas21_7.Fmin","branch2_amesim_mecmas21_7.Fmax","branch2_amesim_mecmas21_7.port_1.x","branch2_amesim_mecmas21_7.port_1.v","branch2_amesim_mecmas21_7.port_1.f","branch2_amesim_mecmas21_7.port_2.x","branch2_amesim_mecmas21_7.port_2.v","branch2_amesim_mecmas21_7.port_2.f"],"initialState":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0,1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"probes":[{"time":0,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0,1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[0.0,0.0,0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.8167936695810981,0.0,5882352.94117647,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,0.0,0.015770552842049427,0.0,0.0,15299999.999999808,0.0,-25942.86971068256,100000.0,0.0,-10744.47227494699,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1e+17,1e+17,1e+17,1e+17,0.0,0.0,-1e+17,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,1000000000000.0,1000000000000.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17,1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,0.0,0.015770552842049427,0.0,0.0,15299999.999999808,0.0,-25942.86971068256,100000.0,0.0,-10744.47227494699,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1e+17,1e+17,1e+17,1e+17,0.0,0.0,-1e+17,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,1000000000000.0,1000000000000.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17]},{"time":0.1,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0,1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,5882352.94117647,0.0,0.0,0.0,-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,5882352.94117647,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1e+17,1e+17,1e+17,1e+17,0.0,0.0,-1e+17,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,1000000000000.0,1000000000000.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17,1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,1e+17,1e+17,1e+17,1e+17,0.0,0.0,-1e+17,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,1000000000000.0,0.0,0.0,-1000000000000.0,1000000000000.0,1000000000000.0,5882352.94117647,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1000000000000.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,1e+17]},{"time":1,"state":[1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0,1.3745108503351984,-886868.393723014,0.002462855848963059,-1563.893548405841,0.002462855848963059,-1563.893548405841,0.0,0.0,0.0,0.0,0.0,0.0],"rhs":[-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,-0.4978237019458235,5348.852963368374,0.4978237019458235,-5348.852963368374,0.0,0.0,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0],"outputs":[1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,49000.0,1.3745108503351984,-886868.393723014,15299999.999999808,293.14999999999634,24.114225444477164,-645224.7310428548,-10744.47227494699,15299999.999999808,0.0,-10744.47227494699,15299999.999999808,-0.4978237019458235,-10744.47227494699,1.0,0.015770552842049427,895.7699085595057,1.0,15299999.999999808,0.4978237019458235,-25942.86971068256,100000.0,-0.4978237019458235,-10744.47227494699,1.0,1.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.4978237019458235,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,49000.0,49000.0,49000.0,49000.0,0.0,0.0,-49000.0,0.0,0.0,0.0,-40.839683479054905,100000.0,0.0,-25942.86971068256,0.0,0.0,40.839683479054905,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.002462855848963059,-1563.893548405841,100000.0,293.15,0.16419038993087062,-634991.9135804437,-25942.86971068256,0.015,0.0,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,15299999.999999808,0.0,-10744.47227494699,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,100000.0,0.0,-25942.86971068256,-0.8167936695810981,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-40.839683479054905,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,-0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,40.839683479054905,0.0,0.0,49000.0]}]}]} \ No newline at end of file diff --git a/tests/data/native-skill-test.xml b/tests/data/native-skill-test.xml new file mode 100644 index 0000000..6ec9a82 --- /dev/null +++ b/tests/data/native-skill-test.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/data/test-mql-8.json b/tests/data/test-mql-8.json index 9a419ba..50c4719 100644 --- a/tests/data/test-mql-8.json +++ b/tests/data/test-mql-8.json @@ -110,7 +110,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -156,7 +156,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -389,7 +389,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -435,7 +435,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -623,7 +623,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -669,7 +669,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -812,7 +812,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -858,7 +858,7 @@ "parameters": { "gi": 1, "cq": 0.9, - "area": 0.0000785, + "area": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1 @@ -1913,7 +1913,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -1967,7 +1967,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -2021,7 +2021,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -2075,7 +2075,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -2129,7 +2129,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -2183,7 +2183,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -2237,7 +2237,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -2291,7 +2291,7 @@ "parameters": { "gi": 1, "cq": 0.45, - "area0": 0.0000785, + "area0": 7.85e-05, "Cv": 0.5, "Kv": 0.4, "flowset": 1, @@ -5845,8 +5845,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -5927,8 +5927,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -6009,8 +6009,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -6091,8 +6091,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -6173,8 +6173,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -6255,8 +6255,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -6337,8 +6337,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -6419,8 +6419,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -1, @@ -6735,7 +6735,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -6791,7 +6791,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -6847,7 +6847,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -6903,7 +6903,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -6959,7 +6959,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -7015,7 +7015,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -7071,7 +7071,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -7127,7 +7127,7 @@ "sdiam": 0.02, "wdiam": 0.002, "rcont": 100000000000, - "Pdis": 1.0000000000000001e-7, + "Pdis": 1.0000000000000001e-07, "stiffmode": 1, "discContactOption": 1 }, @@ -7373,8 +7373,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": -0.72, @@ -7751,8 +7751,8 @@ "fcoul": 0, "rvisc": 0, "wind": 0, - "dvel": 0.000001, - "restdvel": 0.000001, + "dvel": 1e-06, + "restdvel": 1e-06, "restcoeff": 0.65, "astrib": 0.001, "xmin": 0, @@ -10198,7 +10198,7 @@ "t_start": 0, "t_stop": 10, "step": 0.01, - "max_step": 0.001, + "max_step": 0.02, "method": "BDF" } } diff --git a/tests/data/test-mql-8.xml b/tests/data/test-mql-8.xml index 85fcd80..008e0fe 100644 --- a/tests/data/test-mql-8.xml +++ b/tests/data/test-mql-8.xml @@ -1,6 +1,6 @@ - + diff --git a/tests/native_reference.py b/tests/native_reference.py new file mode 100644 index 0000000..f26fc04 --- /dev/null +++ b/tests/native_reference.py @@ -0,0 +1,30 @@ +"""Rebuild networks from the Python baseline captured before its retirement.""" +import json +from pathlib import Path + +from app.simulation.core.medium import IdealGasMedium +from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium, AmesimIdealAirMedium +from app.simulation.native_codegen.extended import catalog_contracts +from app.simulation.systems.network import SimulationNetwork + + +def reference_data(): + return json.loads((Path(__file__).parent / 'data/native-python-reference.json').read_text(encoding='utf-8')) + + +def reference_network(case): + net = SimulationNetwork('frozen_reference') + media = {} + medium_types = {cls.__name__: cls for cls in (IdealGasMedium, AmesimHeliumPengRobinsonMedium, AmesimIdealAirMedium)} + for item in case['components']: + spec = item['medium'] + key = json.dumps(spec, sort_keys=True) + if key not in media: + media[key] = medium_types[spec['type']](**spec['parameters']) if spec else IdealGasMedium() + component = catalog_contracts()[item['type']].create(name=item['name'], medium=media[key], parameters=item['parameters']) + for name, value in item['overrides'].items(): + setattr(component, name, value) + net.add_component(component) + for left, right in case['connections']: + net.connect(*left, *right) + return net diff --git a/tests/test_amesim_component_migration_matrix.py b/tests/test_amesim_component_migration_matrix.py deleted file mode 100644 index 1a7545c..0000000 --- a/tests/test_amesim_component_migration_matrix.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql import system as test_mql - - -MATRIX_PATH = ( - Path(__file__).resolve().parents[1] - / "docs" - / "other" - / "amesim-component-migration-matrix.md" -) - - -class AmesimComponentMigrationMatrixTests(unittest.TestCase): - def test_matrix_covers_every_test_mql_submodel_family(self) -> None: - text = MATRIX_PATH.read_text(encoding="utf-8") - families = set(test_mql.SUBMODEL_COUNTS) | set(test_mql.LINE_SUBMODEL_COUNTS) - - missing = sorted(family for family in families if f"`{family}`" not in text) - - self.assertEqual(missing, []) - - def test_pnrp17_is_marked_as_the_first_public_cross_domain_component(self) -> None: - text = MATRIX_PATH.read_text(encoding="utf-8") - row = next( - line for line in text.splitlines() if "| `PNRP17` |" in line - ) - - self.assertIn("第一版公开", row) - self.assertIn("`amesim_pnrp17`", row) - self.assertNotIn("暂不公开", row) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_gas_registry.py b/tests/test_amesim_gas_registry.py index ee58ea0..f81b322 100644 --- a/tests/test_amesim_gas_registry.py +++ b/tests/test_amesim_gas_registry.py @@ -155,7 +155,6 @@ class AmesimGasRegistryTests(unittest.TestCase): self.assertEqual(medium.PROPERTY_METHOD_ID, "ideal_gas") self.assertEqual(medium.R_gas, 287.0) self.assertEqual(medium.cp_ref, 1005.0) - self.assertEqual(medium.cv, 718.0) def test_gi_normalization_rejects_fractional_and_out_of_range_values(self) -> None: self.assertEqual(normalize_amesim_gas_index(0.0), 0) @@ -369,7 +368,7 @@ class AmesimGasRegistryTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "gi.*at least 1"): compile_reactflow_network(project) - def test_default_compile_uses_air_before_state_initialization(self) -> None: + def test_default_compile_selects_air_without_python_state(self) -> None: project = ReactFlowProjectPayload( projectSchemaVersion=1, name="default-air", @@ -380,12 +379,9 @@ class AmesimGasRegistryTests(unittest.TestCase): chamber = network.components["chamber_1"] self.assertEqual(chamber.medium.name, "AMESimAirIdealGas") - self.assertAlmostEqual( - chamber.state.m, - chamber.medium.density(200000.0, 300.0) * 0.01, - ) + self.assertFalse(hasattr(chamber, "state")) - def test_gi_is_resolved_before_dynamic_state_initialization(self) -> None: + def test_gi_is_resolved_before_native_compilation(self) -> None: registry, _, future_helium = two_slot_registry() project = ReactFlowProjectPayload( projectSchemaVersion=1, @@ -400,14 +396,6 @@ class AmesimGasRegistryTests(unittest.TestCase): chamber = network.components["chamber_1"] self.assertIs(chamber.medium, future_helium) - self.assertAlmostEqual( - chamber.state.m, - future_helium.density(200000.0, 300.0) * 0.01, - ) - self.assertNotAlmostEqual( - chamber.state.m, - IdealGasMedium().density(200000.0, 300.0) * 0.01, - ) def test_selected_medium_is_injected_into_the_whole_pneumatic_circuit( self, diff --git a/tests/test_amesim_helium_medium.py b/tests/test_amesim_helium_medium.py deleted file mode 100644 index d4054e8..0000000 --- a/tests/test_amesim_helium_medium.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.main import ReactFlowProjectPayload, compile_reactflow_network -from app.simulation.components.amesim.library import LIBRARY -from app.simulation.components.amesim.media.mediums import ( - AMESIM_AIR_PROPERTY_MODELS, - AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL, - AMESIM_HELIUM_PROPERTY_MODELS, - AmesimHeliumPengRobinsonMedium, -) -from app.simulation.core.errors import RecoverableTrialStateError -from app.simulation.core.medium import IdealGasMedium -from app.simulation.core.peng_robinson import HELIUM_PR -from app.simulation.property_cache import property_cache_run -from app.simulation.registry import COMPONENT_MODEL_REGISTRY -from tests.test_generic_system_xml_simulation import component_node -from tests.test_system_xml_protocol import physical_port - - -class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase): - def test_uses_amesim_2404_peng_robinson_and_nasa_constants( - self, - ) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pressure = 15.3e6 - temperature = 293.15 - - density = medium.density(pressure, temperature) - volume = 0.02 - - self.assertIs(medium.fluid, HELIUM_PR) - self.assertEqual(medium.SUBSTANCE_ID, "helium") - self.assertEqual(medium.PROPERTY_METHOD_ID, "peng_robinson") - self.assertAlmostEqual(medium.R_gas, HELIUM_PR.specific_gas_constant) - self.assertEqual(medium.cp_ref, 2.5 * medium.R_gas) - self.assertEqual(medium.cv, 1.5 * medium.R_gas) - self.assertEqual(medium.cp_at_temperature(400.0), 2.5 * medium.R_gas) - self.assertEqual(medium.cv_at_temperature(400.0), 1.5 * medium.R_gas) - self.assertAlmostEqual(medium.gamma, 5.0 / 3.0) - self.assertAlmostEqual(density, HELIUM_PR.density(pressure, temperature)) - self.assertAlmostEqual( - medium.pressure(density * volume, temperature, volume), - pressure, - delta=pressure * 1.0e-12, - ) - - def test_nasa_caloric_reference_and_sutherland_viscosity_complete_contract( - self, - ) -> None: - medium = AmesimHeliumPengRobinsonMedium() - temperature = 340.0 - - internal_energy = medium.specific_internal_energy(temperature) - enthalpy = medium.specific_enthalpy(temperature) - - self.assertEqual( - internal_energy, - medium.R_gas * (1.5 * temperature - 745.375), - ) - self.assertEqual( - enthalpy, - medium.R_gas * (2.5 * temperature - 745.375), - ) - self.assertEqual( - medium.temperature_from_internal_energy(internal_energy), - temperature, - ) - self.assertEqual( - medium.temperature_from_enthalpy(enthalpy), - temperature, - ) - self.assertAlmostEqual(medium.dynamic_viscosity(293.15), 1.96e-5) - self.assertAlmostEqual( - medium.diagnostic_dynamic_viscosity(293.15), - 1.9616132203938165e-5, - ) - self.assertEqual(medium.sutherland_constant, 79.4) - - def test_pipe_diagnostic_viscosity_reproduces_amesim_nasa_table(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - - self.assertEqual( - medium.nasa_viscosity_coefficients, - (0.7501594, 35.76324, -2212.129, 0.9212635), - ) - self.assertAlmostEqual( - medium.diagnostic_dynamic_viscosity(233.92077861710192), - 1.683135986478913e-5, - delta=1.0e-19, - ) - self.assertNotAlmostEqual( - medium.diagnostic_dynamic_viscosity(233.92077861710192), - medium.dynamic_viscosity(233.92077861710192), - delta=1.0e-8, - ) - - with self.assertRaises(ValueError): - medium.diagnostic_dynamic_viscosity(0.0) - - def test_pressure_transport_enthalpy_includes_peng_robinson_departure( - self, - ) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pressure = 13_839_965.0 - temperature = 287.7322 - - ideal_enthalpy = medium.specific_enthalpy(temperature) - transport_enthalpy = medium.specific_enthalpy_at_pressure(pressure, temperature) - - self.assertGreater( - transport_enthalpy, - ideal_enthalpy, - ) - self.assertAlmostEqual( - medium.temperature_from_pressure_enthalpy(pressure, transport_enthalpy), - temperature, - delta=1.0e-8, - ) - - with property_cache_run() as cache: - assert cache is not None - first = medium.temperature_from_pressure_enthalpy( - pressure, - transport_enthalpy, - ) - after_first = cache.info() - second = medium.temperature_from_pressure_enthalpy( - pressure, - transport_enthalpy, - ) - after_second = cache.info() - self.assertEqual(second, first) - self.assertEqual(after_first.misses, 1) - self.assertEqual(after_second.hits, 1) - - density = medium.density(pressure, temperature) - volume = 0.01 - properties = medium.properties_from_mU( - density * volume, - density - * volume - * medium.specific_internal_energy_at_pressure(pressure, temperature), - volume, - ) - self.assertAlmostEqual(properties.p, pressure, delta=pressure * 1.0e-10) - self.assertAlmostEqual(properties.T, temperature, delta=1.0e-8) - self.assertAlmostEqual( - properties.h, - transport_enthalpy, - delta=2.0e-8, - ) - - def test_negative_mass_is_a_recoverable_integrator_trial_state(self) -> None: - media = (IdealGasMedium(), AmesimHeliumPengRobinsonMedium()) - - for medium in media: - with self.subTest(medium=medium.name): - with self.assertRaises(RecoverableTrialStateError): - medium.temperature_from_mass_internal_energy(-1.0, 1.0) - with self.assertRaises(RecoverableTrialStateError): - medium.properties_from_mU(-1.0, 1.0, 1.0) - - def test_exact_repeated_real_gas_state_recovery_is_cached(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pressure = 15.3e6 - temperature = 293.15 - volume = 0.057 - mass = medium.density(pressure, temperature) * volume - internal_energy = mass * medium.specific_internal_energy_at_pressure( - pressure, - temperature, - ) - - with property_cache_run(): - first = medium.properties_from_mU(mass, internal_energy, volume) - second = medium.properties_from_mU(mass, internal_energy, volume) - changed = medium.properties_from_mU( - mass, - internal_energy, - volume * 1.01, - ) - - self.assertIs(second, first) - self.assertIsNot(changed, first) - - def test_amesim_2404_real_gas_isentropic_factor_reference(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - - self.assertAlmostEqual( - medium.isentropic_density_pressure_factor( - 15_201_996.778497815, - 292.3997890954483, - 405_072.8123335606, - ), - 0.5807873871273339, - delta=1.0e-12, - ) - - def test_definition_maps_local_selector_to_amesim_fluid_and_eos_codes( - self, - ) -> None: - self.assertEqual(LIBRARY.version, "0.3.0") - self.assertEqual(AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL, 0) - self.assertEqual(AMESIM_HELIUM_PROPERTY_MODELS[0].value, 0) - self.assertEqual(AMESIM_HELIUM_PROPERTY_MODELS[0].eos_type, 6) - self.assertEqual(AMESIM_AIR_PROPERTY_MODELS[0].eos_type, 1) - - spec = COMPONENT_MODEL_REGISTRY["amesim_helium_medium"] - property_model = spec.parameter_by_name["property_model"] - - self.assertEqual(spec.display.category_id, "media") - self.assertEqual(spec.display.role, "amesimGasMediumDefinition") - self.assertEqual(spec.display.label, "氦气介质定义") - self.assertEqual(spec.model_version, "0.1.0") - self.assertEqual(spec.ports, ()) - self.assertEqual(property_model.default, 0.0) - self.assertEqual(property_model.editor, "amesimGasPropertyModel") - self.assertEqual( - [(option.value, option.label) for option in property_model.options], - [(0, "Peng–Robinson")], - ) - - component = spec.create( - "helium_properties", - IdealGasMedium(), - {"gi": 2.0, "property_model": 0.0}, - ) - definition = component.gas_definition() - - self.assertEqual(component.parameter_values, {"gi": 2.0, "property_model": 0.0}) - self.assertEqual(definition.label, "氦气(Peng–Robinson)") - self.assertEqual(definition.fluid_type, 12) - self.assertEqual(definition.eos_type, 6) - self.assertIsInstance(definition.medium, AmesimHeliumPengRobinsonMedium) - - def test_project_compile_injects_helium_before_dynamic_state_initialization( - self, - ) -> None: - pressure = 13_839_965.0 - temperature = 287.7322 - volume = 0.01 - project = ReactFlowProjectPayload( - projectSchemaVersion=1, - name="helium-peng-robinson-compile", - nodes=[ - component_node( - "helium_properties", - "amesim_helium_medium", - [], - {"gi": 2.0, "property_model": 0.0}, - ), - component_node( - "chamber_1", - "amesim_pnch023", - [ - physical_port("port_1", "bidirectional", "left"), - physical_port("port_2", "bidirectional", "right"), - ], - { - "gi": 2.0, - "cvol": volume, - "kth": 0.0, - "sth": 0.1, - "extemp": 293.15, - "p0": pressure, - "T0": temperature, - }, - ), - ], - ) - - network = compile_reactflow_network(project) - chamber = network.components["chamber_1"] - - self.assertNotIn("helium_properties", network.components) - self.assertIsInstance(chamber.medium, AmesimHeliumPengRobinsonMedium) - self.assertAlmostEqual( - chamber.state.m, - HELIUM_PR.density(pressure, temperature) * volume, - delta=1.0e-12, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_helium_step_long_run.py b/tests/test_amesim_helium_step_long_run.py index a5e4155..d82f313 100644 --- a/tests/test_amesim_helium_step_long_run.py +++ b/tests/test_amesim_helium_step_long_run.py @@ -219,15 +219,6 @@ class AmesimHeliumStepLongRunTests(unittest.TestCase): self.assertTrue(result["success"], result["message"]) self.assertEqual(result["status"], "completed") self.assertAlmostEqual(result["simulatedUntil"], 10.0) - self.assertEqual(result["diagnostics"]["signal"]["eventTimes"], [0.04]) - self.assertLessEqual( - result["diagnostics"]["pressureFlow"]["maxEvaluationsPerSolve"], - 5, - ) - self.assertLessEqual( - result["diagnostics"]["pressureFlow"]["maxScaledResidual"], - 1.0e-7, - ) series = result["series"] self.assertIn(3.04, series["time"]) diff --git a/tests/test_amesim_mechanical_components.py b/tests/test_amesim_mechanical_components.py deleted file mode 100644 index da4f8ff..0000000 --- a/tests/test_amesim_mechanical_components.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.primitives.mechanical import ( - AmesimElasticEndstop, - AmesimMassFrictionEndstops, - AmesimPistonGeometry, - circular_area, - m_to_mm, - mm_to_m, -) -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class AmesimMechanicalComponentsTest(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_unit_helpers_and_area(self) -> None: - self.assertAlmostEqual(mm_to_m(200.0), 0.2) - self.assertAlmostEqual(m_to_mm(0.37), 370.0) - self.assertAlmostEqual(circular_area(0.2), 0.031415926535897934) - - def test_piston_geometry_matches_amesim_length_and_volume(self) -> None: - geometry = AmesimPistonGeometry(piston_diameter_m=0.2, rod_diameter_m=0.001) - index = -1 - x4 = self.amesim_results.series("x4@pn_brp2_8")[index] - x5 = self.amesim_results.series("x5@pn_brp2_8")[index] - - self.assertAlmostEqual( - geometry.chamber_length_mm(x4, x5), - self.amesim_results.series("length@pn_brp2_8")[index], - ) - self.assertAlmostEqual( - geometry.chamber_volume_cm3(x4, x5), - self.amesim_results.series("vol1@pn_brp2_8")[index], - delta=1.0e-3, - ) - - def test_piston_geometry_matches_amesim_volume_rate(self) -> None: - geometry = AmesimPistonGeometry(piston_diameter_m=0.2, rod_diameter_m=0.001) - index = 100 - v4 = self.amesim_results.series("v4@pn_brp2_8")[index] - v5 = self.amesim_results.series("v5@pn_brp2_8")[index] - - self.assertAlmostEqual( - geometry.chamber_volume_rate_l_min(v4, v5), - self.amesim_results.series("vvol1@pn_brp2_8")[index], - delta=1.0e-8, - ) - - def test_elastic_endstop_contact_force_matches_amesim_final_sample(self) -> None: - endstop = AmesimElasticEndstop( - contact_stiffness_n_per_m=1.0e11, - contact_damping_n_per_m_per_s=1.0e11, - ) - index = -1 - gap_mm = self.amesim_results.series("gap@elasticendstop_8")[index] - penetration_velocity_m_s = self.amesim_results.series("v5@pn_brp2_8")[index] - - self.assertAlmostEqual( - endstop.contact_force(gap_mm, penetration_velocity_m_s), - self.amesim_results.series("f1@elasticendstop_8")[index], - delta=1.0e-3, - ) - - def test_elastic_endstop_returns_zero_before_contact(self) -> None: - endstop = AmesimElasticEndstop(contact_stiffness_n_per_m=1.0e11) - - self.assertEqual(endstop.static_contact_force(0.1), 0.0) - self.assertEqual(endstop.penetration_m_from_gap_mm(0.1), 0.0) - - def test_mass_endstop_penetration_and_viscous_friction(self) -> None: - mass = AmesimMassFrictionEndstops( - mass_kg=50.0, - lower_limit_m=-1.0, - upper_limit_m=0.8, - lower_stiffness_n_per_m=1.0e9, - upper_stiffness_n_per_m=1.0e9, - viscous_friction_n_per_m_per_s=12.0, - ) - - self.assertEqual(mass.lower_penetration_m(0.0), 0.0) - self.assertEqual(mass.upper_penetration_m(0.0), 0.0) - self.assertAlmostEqual(mass.lower_static_force_magnitude(-1.001), 1.0e6, delta=1.0e-6) - self.assertAlmostEqual(mass.upper_static_force_magnitude(0.801), 1.0e6, delta=1.0e-6) - self.assertAlmostEqual(mass.viscous_friction_force(0.25), -3.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_mechanical_public_components.py b/tests/test_amesim_mechanical_public_components.py index bf815d6..b54418c 100644 --- a/tests/test_amesim_mechanical_public_components.py +++ b/tests/test_amesim_mechanical_public_components.py @@ -23,50 +23,8 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase): (("port_2", "left"), ("port_1", "right")), ) - def test_f000_constrains_mechanical_port_force_to_zero(self) -> None: - source = AmesimF000("zero_1") - source.port_1.f = 12.5 - residuals = source.pressure_flow_equation_residuals() - self.assertEqual(len(residuals), 1) - self.assertEqual(residuals[0].variables, ("zero_1.port_1.f",)) - self.assertAlmostEqual(residuals[0].value, 12.5) - - def test_forc_converts_signal_to_opposing_source_port_force(self) -> None: - converter = AmesimForc("force_1") - converter.res.signal = 20.0 - converter.port_2.f = -20.0 - - residuals = converter.pressure_flow_equation_residuals() - - self.assertAlmostEqual(converter.output_force, 20.0) - self.assertEqual( - converter.pressure_flow_equation_values(), - tuple(residual.value for residual in residuals), - ) - self.assertAlmostEqual(residuals[0].value, 0.0) - self.assertEqual(converter.component_result_values(), {"force": 20.0}) - self.assertEqual(converter.parameter_values, {"direction": 1.0}) - - def test_forc_negative_direction_reverses_applied_force(self) -> None: - converter = AmesimForc.create( - name="force_reverse", - medium=self.medium, - parameters={"direction": -1.0}, - ) - converter.res.signal = 20.0 - converter.port_2.f = 20.0 - - residuals = converter.pressure_flow_equation_residuals() - - self.assertEqual( - converter.pressure_flow_equation_values(), - tuple(residual.value for residual in residuals), - ) - self.assertAlmostEqual(residuals[0].value, 0.0) - self.assertEqual(converter.parameter_values, {"direction": -1.0}) - self.assertEqual(converter.component_result_values(), {"force": -20.0}) def test_forc_direction_only_accepts_positive_or_negative_one(self) -> None: definition = AmesimForc.PARAMETERS[0] @@ -81,46 +39,7 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "must be one of 1, -1"): AmesimForc("invalid_force", direction=0.0) - def test_mecmas21_acceleration_uses_connected_port_forces(self) -> None: - mass = AmesimMecmas21( - "mass_1", - self.medium, - mass=2.0, - fcoul=0.0, - rvisc=0.0, - wind=0.0, - x0=0.1, - v0=0.2, - ) - mass.port_1.f = 10.0 - mass.port_2.f = -2.0 - self.assertEqual(mass.get_state_vector(), [0.2, 0.1]) - self.assertAlmostEqual(mass.acceleration(), 4.0) - self.assertEqual(mass.state_derivative_from_ports({}), [4.0, 0.2]) - self.assertAlmostEqual(mass.port_1.x, 0.1) - self.assertAlmostEqual(mass.port_2.v, 0.2) - - def test_mecmas21_uses_amesim_one_two_friction_encoding(self) -> None: - disabled = AmesimMecmas21( - "mass_disabled", - self.medium, - useFriction=1.0, - fcoul=5.0, - v0=2.0, - ) - enabled = AmesimMecmas21( - "mass_enabled", - self.medium, - useFriction=2.0, - fcoul=5.0, - v0=2.0, - ) - - self.assertFalse(disabled.use_friction) - self.assertEqual(disabled._dry_friction_force(), 0.0) - self.assertTrue(enabled.use_friction) - self.assertEqual(enabled._dry_friction_force(), -5.0) def test_mecmas21_choice_metadata_preserves_amesim_codes(self) -> None: definitions = { @@ -223,34 +142,6 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase): with self.subTest(always_visible=name): self.assertEqual(definitions[name].visible_when, ()) - def test_lmechn1_ties_active_port_kinematics_and_balances_force(self) -> None: - node = AmesimLmechn1("node_1", self.medium, v1=2.0, sum=1.0) - node.port_1.x = 0.2 - node.port_1.v = 0.3 - node.port_1.f = 4.0 - node.port_2.x = 0.2 - node.port_2.v = 0.3 - node.port_2.f = 6.0 - node.port_3.x = 0.2 - node.port_3.v = 0.3 - node.port_3.f = -10.0 - - residuals = node.pressure_flow_equation_residuals() - - self.assertEqual(node.active_ports, ("port_1", "port_2", "port_3")) - self.assertEqual( - tuple(definition.name for definition in node.active_port_definitions), - node.active_ports, - ) - self.assertAlmostEqual(node.total_force, 10.0) - self.assertAlmostEqual(node.force_balance, 0.0) - self.assertEqual(len(residuals), 5) - self.assertEqual( - node.pressure_flow_equation_values(), - tuple(residual.value for residual in residuals), - ) - self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals)) - self.assertEqual(node.component_result_values(), {"tforce": 10.0}) def test_lmechn1_defaults_to_two_right_ports(self) -> None: node = AmesimLmechn1("node_default", self.medium) @@ -276,39 +167,7 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "must be at most 20"): AmesimLmechn1("node_21", self.medium, v1=21.0, sum=1.0) - def test_lstp00a_generates_opposing_contact_forces(self) -> None: - contact = AmesimLstp00a( - "contact_1", - self.medium, - gap0=0.0, - kcont=1000.0, - rcont=10.0, - ) - contact.port_1.x = 0.002 - contact.port_2.x = 0.0 - contact.port_1.v = 0.1 - contact.port_2.v = 0.0 - contact.port_1.f = 3.0 - contact.port_2.f = -3.0 - self.assertAlmostEqual(contact.gap, -0.002) - self.assertAlmostEqual(contact.penetration, 0.002) - self.assertAlmostEqual(contact.contact_force, 3.0) - self.assertEqual( - contact.component_result_values(), - {"gap": -0.002, "penetration": 0.002, "force": 3.0}, - ) - residuals = contact.pressure_flow_equation_residuals() - self.assertAlmostEqual(residuals[0].value, 0.0) - self.assertAlmostEqual(residuals[1].value, 0.0) - - def test_lstp00a_returns_zero_before_contact(self) -> None: - contact = AmesimLstp00a("contact_1", self.medium, gap0=0.001, kcont=1000.0) - contact.port_1.x = 0.0005 - contact.port_2.x = 0.0 - - self.assertAlmostEqual(contact.gap, 0.0005) - self.assertAlmostEqual(contact.contact_force, 0.0) def test_mecmas21_registry_rejects_fractional_choice_value(self) -> None: with self.assertRaisesRegex(ValueError, "must be one of"): diff --git a/tests/test_amesim_mechanical_xml.py b/tests/test_amesim_mechanical_xml.py index adb6317..c393b5a 100644 --- a/tests/test_amesim_mechanical_xml.py +++ b/tests/test_amesim_mechanical_xml.py @@ -10,8 +10,6 @@ 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 from tests.test_generic_system_xml_simulation import component_node, physical_edge @@ -293,149 +291,10 @@ def force_node_mass_project(right_port_count: int = 8) -> ReactFlowProjectPayloa class AmesimMechanicalXmlTests(unittest.TestCase): - def test_mechanical_states_use_machine_scale_absolute_tolerances(self) -> None: - system = GenericFluidSystem( - compile_reactflow_network(zero_force_mass_project()) - ) - tolerances = system.mechanical_state_reducer.absolute_tolerances(1.0e-8) - 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: diff --git a/tests/test_amesim_pnch012_component.py b/tests/test_amesim_pnch012_component.py index 0008310..768d90f 100644 --- a/tests/test_amesim_pnch012_component.py +++ b/tests/test_amesim_pnch012_component.py @@ -11,109 +11,11 @@ class AmesimPnch012ComponentTests(unittest.TestCase): def setUp(self) -> None: self.medium = IdealGasMedium() - def test_default_create_preserves_parameterized_volume_contract(self) -> None: - chamber = COMPONENT_MODEL_REGISTRY["amesim_pnch012"].create( - "chamber_1", - self.medium, - {}, - ) - self.assertIsInstance(chamber, AmesimPnch012) - self.assertEqual(set(chamber.ports), {"port_1", "port_2", "port_3", "port_4"}) - self.assertEqual(len(chamber.get_state_vector()), 2) - self.assertEqual(chamber.parameter_values["cvol0"], 0.015) - self.assertAlmostEqual(chamber.total_volume(), 0.015) - def test_total_volume_adds_four_external_volume_parameters(self) -> None: - chamber = AmesimPnch012( - "chamber_1", - self.medium, - cvol0=0.015, - vol1=0.001, - vol2=0.002, - vol3=0.003, - vol4=0.004, - dvol1=1.0e-6, - dvol2=2.0e-6, - dvol3=-1.0e-6, - dvol4=0.0, - ) - self.assertAlmostEqual(chamber.total_volume(), 0.025) - self.assertAlmostEqual(chamber.total_volume_rate(), 2.0e-6) - def test_initial_state_uses_total_volume_pressure_and_temperature(self) -> None: - chamber = AmesimPnch012( - "chamber_1", - self.medium, - cvol0=0.015, - vol1=0.010, - p0=200000.0, - T0=300.0, - ) - props = chamber.properties() - self.assertAlmostEqual(props.p, 200000.0, delta=1.0e-8) - self.assertAlmostEqual(props.T, 300.0) - self.assertAlmostEqual(chamber.port_1.p, props.p) - self.assertAlmostEqual(chamber.port_4.p, props.p) - - def test_pressure_flow_residuals_bind_all_ports_to_chamber_state(self) -> None: - chamber = AmesimPnch012("chamber_1", self.medium) - chamber.properties() - - residuals = { - residual.id.rsplit(":", 1)[1]: residual - for residual in chamber.pressure_flow_equation_residuals() - } - - self.assertEqual( - set(residuals), - { - "port_1_pressure_state", - "port_2_pressure_state", - "port_3_pressure_state", - "port_4_pressure_state", - }, - ) - self.assertTrue(all(abs(residual.value) < 1.0e-9 for residual in residuals.values())) - - def test_derivative_sums_four_mass_flows_and_boundary_work(self) -> None: - chamber = AmesimPnch012( - "chamber_1", - self.medium, - dvol1=1.0e-6, - kth=2.0, - sth=0.5, - extemp=310.0, - ) - props = chamber.properties() - chamber.port_1.m_flow = 0.2 - chamber.port_2.m_flow = -0.1 - chamber.port_3.m_flow = 0.05 - chamber.port_4.m_flow = 0.0 - - derivative = chamber.state_derivative_from_ports( - { - "port_1": props.h + 1000.0, - "port_2": props.h - 1000.0, - "port_3": props.h + 500.0, - "port_4": props.h, - } - ) - - self.assertAlmostEqual(derivative[0], 0.15) - self.assertLess(derivative[1], 0.2 * (props.h + 1000.0) + 0.05 * (props.h + 500.0)) - - def test_results_include_total_volume_and_volume_rate(self) -> None: - chamber = AmesimPnch012("chamber_1", self.medium, vol1=0.001, dvol1=1.0e-6) - - values = chamber.component_result_values() - - self.assertIn("vol", values) - self.assertIn("dvol", values) - self.assertAlmostEqual(values["vol"], 0.016) - self.assertAlmostEqual(values["dvol"], 1.0e-6) if __name__ == "__main__": diff --git a/tests/test_amesim_pnch023_component.py b/tests/test_amesim_pnch023_component.py index ca3b4ab..f9adcb5 100644 --- a/tests/test_amesim_pnch023_component.py +++ b/tests/test_amesim_pnch023_component.py @@ -35,61 +35,8 @@ class AmesimPnch023ComponentTests(unittest.TestCase): {"gi": 1.5}, ) - def test_initial_state_matches_requested_pressure_temperature(self) -> None: - chamber = AmesimPnch023( - "chamber_1", - self.medium, - cvol=0.057, - p0=15300000.0, - T0=293.15, - ) - props = chamber.properties() - self.assertAlmostEqual(props.p, 15300000.0) - self.assertAlmostEqual(props.T, 293.15) - self.assertAlmostEqual(chamber.port_1.p, props.p) - self.assertAlmostEqual(chamber.port_2.p, props.p) - - def test_two_port_derivative_conserves_mass_and_adds_heat_transfer(self) -> None: - chamber = AmesimPnch023( - "chamber_1", - self.medium, - kth=10.0, - sth=2.0, - extemp=310.0, - T0=300.0, - ) - props = chamber.properties() - chamber.port_1.m_flow = 0.2 - chamber.port_2.m_flow = -0.1 - - derivative = chamber.state_derivative_from_ports( - { - "port_1": props.h + 1000.0, - "port_2": props.h - 1000.0, - } - ) - - self.assertAlmostEqual(derivative[0], 0.1) - self.assertAlmostEqual( - derivative[1], - 0.2 * (props.h + 1000.0) - 0.1 * props.h + 10.0 * 2.0 * 10.0, - ) - - def test_pressure_residuals_bind_both_ports_to_state(self) -> None: - chamber = AmesimPnch023("chamber_1", self.medium, p0=200000.0, T0=300.0) - chamber.port_1.p = 200100.0 - chamber.port_2.p = 199900.0 - - residuals = { - residual.id.rsplit(":", 1)[1]: residual - for residual in chamber.pressure_flow_equation_residuals() - } - - self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"}) - self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 100.0) - self.assertAlmostEqual(residuals["port_2_pressure_state"].value, -100.0) if __name__ == "__main__": diff --git a/tests/test_amesim_pneumatic_components.py b/tests/test_amesim_pneumatic_components.py deleted file mode 100644 index 3d0c748..0000000 --- a/tests/test_amesim_pneumatic_components.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.primitives.pneumatic import ( - HELIUM_PNEUMATIC_GAS, - AmesimPneumaticGas, - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - AmesimVariablePneumaticVolume, - cm3_to_m3, - compressible_orifice_mass_flow, - diameter_mm_to_area_m2, - kg_to_g, - liters_to_m3, - m3_to_cm3, - mm2_to_m2, -) - - -class AmesimPneumaticComponentsTest(unittest.TestCase): - def test_unit_conversions(self) -> None: - self.assertAlmostEqual(liters_to_m3(15.0), 0.015) - self.assertAlmostEqual(m3_to_cm3(0.015), 15000.0) - self.assertAlmostEqual(cm3_to_m3(15000.0), 0.015) - self.assertAlmostEqual(kg_to_g(1.25), 1250.0) - self.assertAlmostEqual(mm2_to_m2(78.5), 78.5e-6) - self.assertAlmostEqual(diameter_mm_to_area_m2(10.0), 7.853981633974483e-5) - - def test_reference_enthalpy_uses_amesim_operating_temperature_baseline(self) -> None: - gas = AmesimPneumaticGas(cp=5193.0, cv=3116.0) - - self.assertAlmostEqual(gas.specific_reference_enthalpy(298.15), 0.0) - self.assertAlmostEqual( - gas.specific_reference_enthalpy(290.2036), - 5193.0 * (290.2036 - 298.15), - ) - self.assertAlmostEqual( - gas.reference_temperature_from_specific_enthalpy( - gas.specific_reference_enthalpy(287.7322), - ), - 287.7322, - ) - - def test_pressure_reference_enthalpy_includes_real_gas_departure(self) -> None: - gas = AmesimPneumaticGas(cp=5193.0, cv=3116.0) - - ideal_reference_h = gas.specific_reference_enthalpy(287.7322) - pressure_reference_h = gas.pressure_reference_enthalpy(13_839_965.0, 287.7322) - - self.assertAlmostEqual(ideal_reference_h, -54099.6354, delta=0.001) - self.assertAlmostEqual( - pressure_reference_h - ideal_reference_h, - 12762.690087540526, - delta=1.0e-4, - ) - - def test_pressure_transport_enthalpy_restores_absolute_energy_offset(self) -> None: - gas = HELIUM_PNEUMATIC_GAS - pressure = 13_839_965.0 - temperature = 287.7322 - - self.assertAlmostEqual( - gas.pressure_transport_enthalpy(pressure, temperature), - gas.pressure_reference_enthalpy(pressure, temperature) - + gas.cp * 298.15, - ) - - def test_reference_temperature_from_enthalpy_rejects_non_positive_cp(self) -> None: - gas = AmesimPneumaticGas(cp=0.0, cv=3116.0) - - with self.assertRaisesRegex(ValueError, "cp must be positive"): - gas.reference_temperature_from_specific_enthalpy(42.0) - - def test_volume_initial_state_matches_requested_pressure_temperature(self) -> None: - volume = AmesimPneumaticVolume.from_liters( - name="pn_general_chamber", - volume_liters=57.0, - p0=15.3e6, - T0=293.15, - ) - props = volume.properties() - - self.assertAlmostEqual(props.p, 15.3e6, delta=15.3e6 * 1.0e-12) - self.assertAlmostEqual(props.T, 293.15) - self.assertGreater(props.rho, 20.0) - self.assertLess(props.rho, 30.0) - - def test_volume_reports_amesim_style_observables(self) -> None: - volume = AmesimPneumaticVolume.from_liters( - name="pn_general_chamber", - volume_liters=57.0, - p0=15.3e6, - T0=293.15, - ) - - self.assertAlmostEqual(volume.volume_cm3(), 57000.0) - self.assertAlmostEqual( - volume.pressure_gauge_pa(reference_pressure_pa=101_300.0), - 15198700.0, - ) - self.assertGreater(volume.gas_mass_g(), 1300.0) - - def test_volume_balances_two_connections_with_stream_enthalpy(self) -> None: - volume = AmesimPneumaticVolume.from_liters( - name="pn_general_chamber", - volume_liters=57.0, - p0=15.3e6, - T0=293.15, - ) - internal_h = volume.properties().h - - derivative = volume.derivatives_from_two_connections( - port_a_m_flow=0.2, - connected_h_a=internal_h + 1000.0, - port_b_m_flow=-0.1, - connected_h_b=internal_h - 1000.0, - internal_h=internal_h, - ) - - self.assertAlmostEqual(derivative.m, 0.1) - self.assertAlmostEqual( - derivative.U, - 0.2 * (internal_h + 1000.0) - 0.1 * internal_h, - ) - self.assertAlmostEqual(volume.port_a.p, volume.port_b.p) - - def test_volume_applies_amesim_heat_exchange_to_energy_derivative(self) -> None: - volume = AmesimPneumaticVolume.from_liters( - name="heated_chamber", - volume_liters=15.0, - p0=100000.0, - T0=300.0, - heat_transfer_coefficient=1500.0, - heat_transfer_area=0.7, - external_temperature_k=293.15, - ) - props = volume.properties() - - derivative = volume.derivatives_from_two_connections( - port_a_m_flow=0.0, - connected_h_a=props.h, - port_b_m_flow=0.0, - connected_h_b=props.h, - internal_h=props.h, - ) - - self.assertAlmostEqual( - derivative.U, - 1500.0 * 0.7 * (293.15 - 300.0), - ) - - def test_variable_volume_tracks_external_volume(self) -> None: - volume = AmesimVariablePneumaticVolume.from_liters( - name="pn_c1_8", - dead_volume_liters=15.0, - p0=100000.0, - T0=293.15, - ) - - self.assertAlmostEqual(volume.volume_cm3(), 15000.0) - volume.set_external_volume_m3(cm3_to_m3(34242.54636512914)) - self.assertAlmostEqual(volume.volume_cm3(), 49242.54636512914) - - def test_variable_volume_boundary_work_uses_external_volume_rate(self) -> None: - volume = AmesimVariablePneumaticVolume.from_liters( - name="pn_c1_8", - dead_volume_liters=15.0, - p0=100000.0, - T0=293.15, - ) - props = volume.properties() - volume.set_external_volume_m3(0.0, external_volume_rate_m3_s=2.0e-6) - - derivative = volume.derivatives_from_two_connections( - port_a_m_flow=0.0, - connected_h_a=props.h, - port_b_m_flow=0.0, - connected_h_b=props.h, - internal_h=props.h, - ) - - self.assertAlmostEqual(derivative.m, 0.0) - self.assertAlmostEqual(derivative.U, -props.p * 2.0e-6) - - def test_orifice_effective_area_clamps_opening(self) -> None: - orifice = AmesimPneumaticOrifice.from_mm2( - name="pn_variable_orifice", - area_mm2=78.5, - opening=1.4, - ) - self.assertAlmostEqual(orifice.effective_area, 78.5e-6) - - orifice.opening = -0.25 - self.assertAlmostEqual(orifice.effective_area, 0.0) - - def test_orifice_returns_signed_mass_flow(self) -> None: - orifice = AmesimPneumaticOrifice.from_mm2( - name="pn_orifice_18", - area_mm2=78.5, - flow_coefficient=0.9, - ) - - forward = orifice.mass_flow(15.3e6, 1.0e6, 293.15) - reverse = orifice.mass_flow(1.0e6, 15.3e6, 293.15) - - self.assertGreater(forward, 0.0) - self.assertAlmostEqual(reverse, -forward) - self.assertEqual(orifice.mass_flow(1.0e6, 1.0e6, 293.15), 0.0) - - def test_choked_flow_is_independent_of_lower_downstream_pressure(self) -> None: - base = compressible_orifice_mass_flow( - upstream_pressure=15.3e6, - downstream_pressure=1.0e6, - upstream_temperature=293.15, - area=78.5e-6, - flow_coefficient=0.9, - gas=HELIUM_PNEUMATIC_GAS, - ) - lower_back_pressure = compressible_orifice_mass_flow( - upstream_pressure=15.3e6, - downstream_pressure=0.1e6, - upstream_temperature=293.15, - area=78.5e-6, - flow_coefficient=0.9, - gas=HELIUM_PNEUMATIC_GAS, - ) - - self.assertGreater(base, 0.0) - self.assertAlmostEqual(lower_back_pressure, base) - - def test_subcritical_flow_decreases_as_back_pressure_rises(self) -> None: - low_back_pressure = compressible_orifice_mass_flow( - upstream_pressure=1.0e6, - downstream_pressure=0.6e6, - upstream_temperature=293.15, - area=78.5e-6, - flow_coefficient=0.9, - ) - high_back_pressure = compressible_orifice_mass_flow( - upstream_pressure=1.0e6, - downstream_pressure=0.9e6, - upstream_temperature=293.15, - area=78.5e-6, - flow_coefficient=0.9, - ) - - self.assertGreater(low_back_pressure, high_back_pressure) - self.assertGreater(high_back_pressure, 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_pneumatic_line_components.py b/tests/test_amesim_pneumatic_line_components.py deleted file mode 100644 index f1825bb..0000000 --- a/tests/test_amesim_pneumatic_line_components.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.primitives.pneumatic_lines import ( - AmesimPnl0002Pipe, - AmesimPnl0003Pipe, - AmesimPnl00rPipe, -) - - -class AmesimPnl0002PipeTests(unittest.TestCase): - def setUp(self) -> None: - self.pipe = AmesimPnl0002Pipe( - name="pneumatic_86", - diameter_mm=20.0, - length_m=2.0, - relative_roughness=0.045 / 20.0, - pctr_0=100_000.0, - Tctr_0=293.15, - ) - - def test_initial_state_uses_center_compliance(self) -> None: - properties = self.pipe.properties() - - self.assertAlmostEqual(self.pipe.volume, 6.283185307179586e-4) - self.assertEqual(len(self.pipe.get_state_vector()), 2) - self.assertAlmostEqual(properties.p, 100_000.0, delta=1.0e-6) - self.assertAlmostEqual(properties.T, 293.15) - self.assertAlmostEqual(self.pipe.port_1.p, properties.p) - self.assertAlmostEqual(self.pipe.port_2.p, properties.p) - - def test_port_flow_enters_center_from_higher_external_pressure(self) -> None: - forward = self.pipe.port_mass_flow( - port_pressure_pa=101_000.0, - port_temperature_k=293.15, - ) - reverse = self.pipe.port_mass_flow( - port_pressure_pa=99_000.0, - port_temperature_k=293.15, - ) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - - def test_connection_derivatives_conserve_two_external_port_flows(self) -> None: - properties = self.pipe.properties() - - derivative = self.pipe.derivatives_from_connections( - port_1_m_flow=0.2, - connected_h_1=properties.h + 1000.0, - port_2_m_flow=-0.1, - connected_h_2=properties.h - 1000.0, - ) - - self.assertAlmostEqual(derivative.m, 0.1) - - -class AmesimPnl0003PipeTests(unittest.TestCase): - def setUp(self) -> None: - self.pipe = AmesimPnl0003Pipe( - name="pneumatic_88", - diameter_mm=20.0, - length_m=0.3, - relative_roughness=0.045 / 20.0, - p1_0=15.3e6, - p2_0=15.3e6, - T1_0=293.15, - T2_0=293.15, - ) - - def test_initial_state_uses_two_half_volume_compliances(self) -> None: - port_1 = self.pipe.properties_1() - port_2 = self.pipe.properties_2() - - self.assertAlmostEqual(self.pipe.volume, 9.424777960769381e-5) - self.assertAlmostEqual(self.pipe.compliance_volume, self.pipe.volume / 2.0) - self.assertEqual(len(self.pipe.get_state_vector()), 4) - self.assertAlmostEqual(port_1.p, 15.3e6, delta=1.0e-5) - self.assertAlmostEqual(port_2.p, 15.3e6, delta=1.0e-5) - self.assertAlmostEqual(port_1.T, 293.15) - self.assertAlmostEqual(port_2.T, 293.15) - - def test_center_resistance_flow_follows_end_pressure_gradient(self) -> None: - state = self.pipe.get_state_vector() - state[0] *= 1.01 - state[1] *= 1.01 - self.pipe.set_state_vector(state) - - forward = self.pipe.resistance_mass_flow() - state[0] /= 1.01 * 1.01 - state[1] /= 1.01 * 1.01 - state[2] *= 1.01 - state[3] *= 1.01 - self.pipe.set_state_vector(state) - reverse = self.pipe.resistance_mass_flow() - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - - def test_connection_derivatives_conserve_internal_center_flow_mass(self) -> None: - state = self.pipe.get_state_vector() - state[0] *= 1.01 - state[1] *= 1.01 - self.pipe.set_state_vector(state) - - d1, d2 = self.pipe.derivatives_from_connections( - port_1_m_flow=0.2, - connected_h_1=self.pipe.properties_1().h + 1000.0, - port_2_m_flow=-0.1, - connected_h_2=self.pipe.properties_2().h - 1000.0, - ) - - self.assertAlmostEqual(d1.m + d2.m, 0.1) - - -class AmesimPnl00rPipeTests(unittest.TestCase): - def setUp(self) -> None: - self.pipe = AmesimPnl00rPipe( - name="pneumatic_100", - diameter_mm=14.0, - length_m=1.0, - relative_roughness=0.045 / 14.0, - ) - - def test_stateless_resistance_flow_follows_pressure_gradient(self) -> None: - forward = self.pipe.mass_flow( - port_1_pressure_pa=15.31e6, - port_1_temperature_k=293.15, - port_2_pressure_pa=15.29e6, - port_2_temperature_k=293.15, - ) - reverse = self.pipe.mass_flow( - port_1_pressure_pa=15.29e6, - port_1_temperature_k=293.15, - port_2_pressure_pa=15.31e6, - port_2_temperature_k=293.15, - ) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01) - - def test_diagnostics_expose_darcy_terms(self) -> None: - diagnostics = self.pipe.diagnostics( - mass_flow_kg_s=1.0e-4, - pressure_pa=15.3e6, - temperature_k=293.15, - ) - - self.assertGreater(diagnostics.reynolds_number, 0.0) - self.assertGreater(diagnostics.gas_velocity_m_s, 0.0) - self.assertGreater(diagnostics.pressure_drop_pa, 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_pneumatic_node_components.py b/tests/test_amesim_pneumatic_node_components.py index 4a9b04e..615981f 100644 --- a/tests/test_amesim_pneumatic_node_components.py +++ b/tests/test_amesim_pneumatic_node_components.py @@ -27,132 +27,10 @@ class AmesimPneumaticNodeComponentTests(unittest.TestCase): ) self.assertEqual(node.parameter_values, {}) - def test_pn3node2_residuals_use_port_2_as_pressure_reference(self) -> None: - node = AmesimPn3Node2("pn3_1") - node.port_1.p = 101000.0 - node.port_2.p = 100000.0 - node.port_3.p = 99000.0 - node.port_1.m_flow = 0.2 - node.port_2.m_flow = -0.3 - node.port_3.m_flow = 0.1 - residuals = {residual.id.rsplit(":", 1)[1]: residual for residual in node.pressure_flow_equation_residuals()} - self.assertEqual(set(residuals), {"port_1_pressure_reference", "port_3_pressure_reference", "mass_flow_balance"}) - self.assertEqual(residuals["port_1_pressure_reference"].value, 1000.0) - self.assertEqual(residuals["port_3_pressure_reference"].value, -1000.0) - self.assertAlmostEqual(residuals["mass_flow_balance"].value, 0.0) - def test_p4node2_residuals_cover_four_ports(self) -> None: - node = AmesimP4Node2("p4_1") - node.port_1.p = 100000.0 - node.port_2.p = 100000.0 - node.port_3.p = 100000.0 - node.port_4.p = 100000.0 - node.port_1.m_flow = 0.1 - node.port_2.m_flow = 0.2 - node.port_3.m_flow = -0.4 - node.port_4.m_flow = 0.1 - residuals = node.pressure_flow_equation_residuals() - - self.assertEqual(len(residuals), 4) - self.assertTrue(all(residual.value == 0.0 for residual in residuals)) - self.assertEqual( - residuals[-1].variables, - ( - "p4_1.port_1.m_flow", - "p4_1.port_2.m_flow", - "p4_1.port_3.m_flow", - "p4_1.port_4.m_flow", - ), - ) - - def test_node_uses_port_2_temperature_for_non_reference_outlets(self) -> None: - node = AmesimP4Node2("p4_1") - node.port_1.m_flow = 0.25 - node.port_2.m_flow = 0.75 - node.port_3.m_flow = -0.5 - node.port_4.m_flow = -0.5 - - node.update_stream_outflows( - { - "port_1": 100.0, - "port_2": 300.0, - "port_3": 900.0, - "port_4": 1200.0, - } - ) - - self.assertEqual(node.temperature_reference_h, 300.0) - self.assertEqual(node.port_1.h_outflow, 300.0) - self.assertEqual(node.port_2.h_outflow, 250.0) - self.assertEqual(node.port_3.h_outflow, 300.0) - self.assertEqual(node.port_4.h_outflow, 300.0) - - def test_port_2_outlet_closes_node_energy_balance(self) -> None: - node = AmesimP4Node2("p4_1") - node.port_1.m_flow = -0.01823 - node.port_2.m_flow = -2.56077 - node.port_3.m_flow = 2.579 - node.port_4.m_flow = 0.0 - - node.update_stream_outflows( - { - "port_1": 2_630_000.0, - "port_2": 140_900.0, - "port_3": 63_960.0, - "port_4": 0.0, - } - ) - - self.assertEqual(node.port_1.h_outflow, 140_900.0) - energy_flow = sum( - port.m_flow - * ( - { - "port_1": 2_630_000.0, - "port_2": 140_900.0, - "port_3": 63_960.0, - "port_4": 0.0, - }[name] - if port.m_flow > 0.0 - else port.h_outflow - ) - for name, port in node.ports.items() - ) - self.assertAlmostEqual(energy_flow, 0.0, places=10) - - def test_port_2_outflow_enthalpy_remains_finite_through_flow_reversal(self) -> None: - node = AmesimP4Node2("p4_1") - connected_h = { - "port_1": 300.0, - "port_2": 300.0, - "port_3": 200.0, - "port_4": 100.0, - } - - outflow_enthalpies: list[float] = [] - for reference_flow in (-1.0e-6, 0.0, 1.0e-6): - node.port_1.m_flow = -1.0 - node.port_2.m_flow = reference_flow - node.port_3.m_flow = -reference_flow - node.port_4.m_flow = 1.0 - - node.update_stream_outflows(connected_h) - - self.assertAlmostEqual( - sum(port.m_flow for port in node.ports.values()), - 0.0, - places=15, - ) - outflow_enthalpies.append(node.port_2.h_outflow) - - self.assertTrue(all(abs(value) < 1.0e4 for value in outflow_enthalpies)) - self.assertLess( - max(outflow_enthalpies) - min(outflow_enthalpies), - 1.0, - ) if __name__ == "__main__": diff --git a/tests/test_amesim_pnl0001_component.py b/tests/test_amesim_pnl0001_component.py index f91ed07..faaee60 100644 --- a/tests/test_amesim_pnl0001_component.py +++ b/tests/test_amesim_pnl0001_component.py @@ -13,60 +13,14 @@ 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() - def test_default_create_preserves_amesim_parameter_contract(self) -> None: - pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0001"].create( - "pnl_1", - self.medium, - {}, - ) - - self.assertIsInstance(pipe, AmesimPnl0001) - self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) - self.assertEqual( - set(pipe.parameter_values), - { - "diam", - "le", - "rr", - "k", - "kth", - "extemp", - "gi", - "mode", - "p0", - "T0", - }, - ) - self.assertEqual(len(pipe.get_state_vector()), 2) - self.assertAlmostEqual(pipe.volume, 7.853981633974483e-5) def test_rejects_fractional_integer_parameters(self) -> None: with self.assertRaisesRegex(ValueError, "gi must be an integer"): @@ -82,298 +36,19 @@ class AmesimPnl0001ComponentTests(unittest.TestCase): {"mode": 1.5}, ) - def test_initial_state_uses_pipe_volume_pressure_and_temperature(self) -> None: - pipe = AmesimPnl0001( - "pnl_1", - self.medium, - diam=0.014, - le=1.0, - rr=0.045 / 14.0, - p0=15.3e6, - T0=293.15, - ) - props = pipe.properties() - self.assertAlmostEqual(pipe.volume, 1.539380400258999e-4) - self.assertAlmostEqual(props.p, 15.3e6, delta=1.0e-5) - self.assertAlmostEqual(props.T, 293.15) - self.assertAlmostEqual(pipe.port_2.p, props.p) - def test_resistance_flow_follows_pressure_gradient(self) -> None: - pipe = AmesimPnl0001( - "pnl_1", - self.medium, - diam=0.014, - le=1.0, - rr=0.045 / 14.0, - p0=15.3e6, - T0=293.15, - ) - props = pipe.properties() - forward = pipe.mass_flow(15.31e6, props.p, props.T) - reverse = pipe.mass_flow(15.29e6, props.p, props.T) - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.02) - def test_high_pressure_near_equal_flow_is_continuous_outside_roundoff(self) -> None: - pipe = AmesimPnl0001("pnl_1", self.medium, p0=15.3e6) - small = pipe.mass_flow(15.3e6 + 0.1, 15.3e6, 293.15) - larger = pipe.mass_flow(15.3e6 + 1.0, 15.3e6, 293.15) - self.assertGreater(small, 0.0) - self.assertGreater(larger, small) - def test_pressure_roundoff_does_not_create_a_fictitious_flow(self) -> None: - pipe = AmesimPnl0001("pnl_1", self.medium, p0=15.3e6) - 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 - pipe.port_2.m_flow = -1.0e-4 - props = pipe.properties() - pipe.port_1.m_flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p, props.T) - - residuals = { - residual.id.rsplit(":", 1)[1]: residual - for residual in pipe.pressure_flow_equation_residuals() - } - - self.assertEqual( - set(residuals), - {"port_2_pressure_state", "port_1_pressure_flow_relation"}, - ) - self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0) - self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0) - - def test_connection_derivative_preserves_two_port_accumulation(self) -> None: - pipe = AmesimPnl0001("pnl_1", self.medium, kth=2.0, extemp=310.0) - props = pipe.properties() - pipe.port_1.m_flow = 0.2 - pipe.port_2.m_flow = -0.1 - - derivative = pipe.state_derivative_from_ports( - { - "port_1": props.h + 1000.0, - "port_2": props.h - 1000.0, - } - ) - - self.assertAlmostEqual(derivative[0], 0.1) - self.assertGreater(derivative[1], 0.0) - - def test_results_include_thermodynamic_and_pipe_diagnostics(self) -> None: - pipe = AmesimPnl0001("pnl_1", self.medium) - pipe.port_1.p = 101000.0 - - values = pipe.component_result_values() - - self.assertEqual( - set(values), - {"m", "U", "p", "T", "rho", "u", "h", "re", "cm", "v", "ff"}, - ) - - def test_result_cm_excludes_pipe_flow_coefficient(self) -> None: - pipe = AmesimPnl0001( - "pnl_1", - self.medium, - diam=0.014, - le=0.3, - rr=0.045 / 14.0, - p0=2.5e6, - T0=290.0, - ) - pipe.port_1.p = 1.6e6 - props = pipe.properties() - flow = pipe.mass_flow(pipe.port_1.p, props.p, props.T) - reynolds = pipe.reynolds_number(flow, props.T) - friction = pipe.friction_factor(reynolds) - raw_cq_cm = ( - abs(flow) - * sqrt(props.T) - / (pipe.area * max(pipe.port_1.p, props.p)) - ) - flow_coefficient = sqrt(pipe.diam / (pipe.le * friction)) - - actual = pipe.component_result_values()["cm"] - - self.assertAlmostEqual(actual, raw_cq_cm / flow_coefficient) - self.assertNotAlmostEqual(actual, raw_cq_cm) if __name__ == "__main__": diff --git a/tests/test_amesim_pnl0001_pipe.py b/tests/test_amesim_pnl0001_pipe.py deleted file mode 100644 index 01a9a8a..0000000 --- a/tests/test_amesim_pnl0001_pipe.py +++ /dev/null @@ -1,147 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.primitives.pneumatic_lines import AmesimPnl0001Pipe - - -class AmesimPnl0001PipeTests(unittest.TestCase): - def setUp(self) -> None: - self.pipe = AmesimPnl0001Pipe( - name="pneumatic_96", - diameter_mm=14.0, - length_m=1.0, - relative_roughness=0.045 / 14.0, - p0=15.3e6, - T0=293.15, - ) - - def test_initial_state_uses_real_pipe_volume_and_two_states(self) -> None: - properties = self.pipe.properties() - - self.assertAlmostEqual(self.pipe.volume, 1.539380400258999e-4) - self.assertEqual(len(self.pipe.get_state_vector()), 2) - self.assertAlmostEqual(properties.p, 15.3e6, delta=1.0e-5) - self.assertAlmostEqual(properties.T, 293.15) - self.assertAlmostEqual( - self.pipe.gas_mass_g(), - self.pipe.gas.density(15.3e6, 293.15) * self.pipe.volume * 1000.0, - places=10, - ) - - def test_resistance_flow_follows_pressure_gradient(self) -> None: - forward = self.pipe.resistance_mass_flow( - port_1_pressure_pa=15.31e6, - port_1_temperature_k=293.15, - ) - reverse = self.pipe.resistance_mass_flow( - port_1_pressure_pa=15.29e6, - port_1_temperature_k=293.15, - ) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01) - - def test_pn2pipefr_candidate_flow_follows_pressure_gradient(self) -> None: - forward = self.pipe.pn2pipefr_mass_flow( - port_1_pressure_pa=15.31e6, - port_1_temperature_k=293.15, - port_2_pressure_pa=15.3e6, - port_2_temperature_k=293.15, - ) - reverse = self.pipe.pn2pipefr_mass_flow( - port_1_pressure_pa=15.29e6, - port_1_temperature_k=293.15, - port_2_pressure_pa=15.3e6, - port_2_temperature_k=293.15, - ) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(abs(forward), abs(reverse), delta=abs(forward) * 0.01) - - def test_calibrated_linear_conductance_overrides_darcy_flow(self) -> None: - pipe = AmesimPnl0001Pipe( - name="linear", - diameter_mm=20.0, - length_m=1.0, - relative_roughness=0.045 / 20.0, - calibrated_linear_conductance=5.5636e-6, - p0=200000.0, - T0=293.15, - ) - flow = pipe.resistance_mass_flow( - port_1_pressure_pa=180000.0, - port_1_temperature_k=293.15, - ) - - self.assertAlmostEqual( - flow, - 5.5636e-6 * (180000.0 - 200000.0) / (293.15 ** 0.5), - ) - - def test_diagnostics_reproduce_laminar_friction_contract(self) -> None: - diagnostics = self.pipe.diagnostics(mass_flow_kg_s=8.90603914774626e-6) - - self.assertGreater(diagnostics.reynolds_number, 40.0) - self.assertLess(diagnostics.reynolds_number, 45.0) - self.assertAlmostEqual( - diagnostics.friction_factor, - 64.0 / diagnostics.reynolds_number, - ) - self.assertGreater(diagnostics.gas_velocity_m_s, 0.0) - self.assertGreater(diagnostics.pressure_drop_pa, 0.0) - - def test_darcy_pressure_drop_for_state_uses_supplied_density_state(self) -> None: - diagnostics = self.pipe.diagnostics(mass_flow_kg_s=8.90603914774626e-6) - pressure_drop = self.pipe.darcy_pressure_drop_for_state( - mass_flow_kg_s=8.90603914774626e-6, - pressure_pa=self.pipe.properties().p, - temperature_k=self.pipe.properties().T, - ) - - self.assertAlmostEqual(pressure_drop, diagnostics.pressure_drop_pa) - - def test_connection_derivative_preserves_mass_and_stream_direction(self) -> None: - internal = self.pipe.properties() - derivative = self.pipe.derivatives_from_connections( - port_1_m_flow=0.2, - connected_h_1=internal.h + 1000.0, - port_2_m_flow=-0.1, - connected_h_2=internal.h - 1000.0, - ) - - self.assertAlmostEqual(derivative.m, 0.1) - self.assertAlmostEqual( - derivative.U, - 0.2 * (internal.h + 1000.0) / self.pipe.gas.gamma - - 0.1 * internal.u, - ) - - def test_transport_enthalpy_derivative_preserves_pn2vol_energy_basis(self) -> None: - internal = self.pipe.properties() - derivative = self.pipe.derivatives_from_transport_enthalpy_connections( - port_1_m_flow=0.2, - connected_h_1=internal.h + 1000.0, - port_2_m_flow=-0.1, - connected_h_2=internal.h - 1000.0, - ) - - self.assertAlmostEqual(derivative.m, 0.1) - self.assertAlmostEqual( - derivative.U, - 0.2 * (internal.h + 1000.0) - 0.1 * internal.h, - ) - temperature_derivative = ( - derivative.U - internal.u * derivative.m - ) / (self.pipe.state.m * self.pipe.gas.cv) - expected_temperature_derivative = ( - 0.2 * (internal.h + 1000.0 - internal.u) - - 0.1 * (internal.h - internal.u) - ) / (self.pipe.state.m * self.pipe.gas.cv) - self.assertAlmostEqual(temperature_derivative, expected_temperature_derivative) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_pnl0002_pnl0003_component.py b/tests/test_amesim_pnl0002_pnl0003_component.py index 8e4ef1e..45a1b83 100644 --- a/tests/test_amesim_pnl0002_pnl0003_component.py +++ b/tests/test_amesim_pnl0002_pnl0003_component.py @@ -15,512 +15,33 @@ class AmesimPnl0002ComponentTests(unittest.TestCase): def setUp(self) -> None: self.medium = IdealGasMedium() - def test_default_create_preserves_contract(self) -> None: - pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0002"].create("pnl_2", self.medium, {}) - self.assertIsInstance(pipe, AmesimPnl0002) - self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) - self.assertEqual(len(pipe.get_state_vector()), 2) - self.assertAlmostEqual(pipe.resistance_length, pipe.le / 2.0) - def test_center_compliance_initial_state(self) -> None: - pipe = AmesimPnl0002( - "pnl_2", - self.medium, - diam=0.02, - le=2.0, - rr=0.045 / 20.0, - p0=100000.0, - T0=293.15, - ) - props = pipe.properties() - self.assertAlmostEqual(pipe.volume, 6.283185307179586e-4) - self.assertAlmostEqual(props.p, 100000.0, delta=1.0e-6) - self.assertAlmostEqual(props.T, 293.15) - def test_port_flows_enter_center_from_higher_external_pressure(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15) - center = pipe.properties() - forward = pipe.port_mass_flow(101000.0, center.p, center.T) - reverse = pipe.port_mass_flow(99000.0, center.p, center.T) - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - def test_exact_repeated_pipe_flow_inversion_is_cached(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15) - pipe._one_way_pn2pipefr_mass_flow.cache_clear() - first = pipe.mass_flow(15.3e6, 10.0e6, 293.15) - after_first = pipe._one_way_pn2pipefr_mass_flow.cache_info() - second = pipe.mass_flow(15.3e6, 10.0e6, 293.15) - after_second = pipe._one_way_pn2pipefr_mass_flow.cache_info() - self.assertEqual(second, first) - self.assertEqual(after_first.misses, 1) - self.assertEqual(after_second.misses, 1) - self.assertEqual(after_second.hits, 1) - def test_external_upstream_flow_uses_connected_stream_temperature(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0) - center = pipe.properties() - external_pressure = 15.3e6 - external_temperature = 293.15 - external_h = self.medium.specific_enthalpy_at_pressure( - external_pressure, - external_temperature, - ) - pipe.update_stream_outflows( - {"port_1": external_h, "port_2": center.h} - ) - flow = pipe.port_mass_flow( - external_pressure, - center.p, - center.T, - port_name="port_1", - ) - expected = pipe.mass_flow( - external_pressure, - center.p, - external_temperature, - ) - center_temperature_flow = pipe.mass_flow( - external_pressure, - center.p, - center.T, - ) - - self.assertAlmostEqual(flow, expected) - self.assertNotAlmostEqual(flow, center_temperature_flow) - - def test_fully_rough_limit_matches_nikuradse_colebrook_asymptote(self) -> None: - relative_roughness = 0.045 / 20.0 - pipe = AmesimPnl0002( - "pnl_2", - self.medium, - diam=0.02, - le=2.0, - rr=relative_roughness, - ) - expected = 1.0 / (-2.0 * log10(relative_roughness / 3.7)) ** 2 - - self.assertAlmostEqual( - pipe.friction_factor(1.0e12), - expected, - delta=1.0e-10, - ) - - def test_missing_stream_cache_preserves_center_temperature_fallback(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0) - center = pipe.properties() - external_pressure = 15.3e6 - - flow = pipe.port_mass_flow( - external_pressure, - center.p, - center.T, - port_name="port_1", - ) - - self.assertAlmostEqual( - flow, - pipe.mass_flow(external_pressure, center.p, center.T), - ) - - def test_center_upstream_flow_keeps_center_temperature(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium, p0=15.3e6, T0=350.0) - center = pipe.properties() - external_pressure = 1.0e5 - external_h = self.medium.specific_enthalpy_at_pressure( - external_pressure, - 293.15, - ) - pipe.update_stream_outflows( - {"port_1": external_h, "port_2": center.h} - ) - - flow = pipe.port_mass_flow( - external_pressure, - center.p, - center.T, - port_name="port_1", - ) - - self.assertAlmostEqual( - flow, - pipe.mass_flow(external_pressure, center.p, center.T), - ) - - def test_pressure_flow_residuals_use_two_port_resistances(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15) - center = pipe.properties() - pipe.port_1.p = 101000.0 - pipe.port_2.p = 99000.0 - pipe.port_1.m_flow = pipe.port_mass_flow(pipe.port_1.p, center.p, center.T) - pipe.port_2.m_flow = pipe.port_mass_flow(pipe.port_2.p, center.p, center.T) - - residuals = { - residual.id.rsplit(":", 1)[1]: residual - for residual in pipe.pressure_flow_equation_residuals() - } - - self.assertEqual( - set(residuals), - {"port_1_pressure_flow_relation", "port_2_pressure_flow_relation"}, - ) - self.assertAlmostEqual(residuals["port_1_pressure_flow_relation"].value, 0.0) - self.assertAlmostEqual(residuals["port_2_pressure_flow_relation"].value, 0.0) - - def test_connection_derivative_accumulates_two_external_flows(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium) - props = pipe.properties() - pipe.port_1.m_flow = 0.2 - pipe.port_2.m_flow = -0.1 - - derivative = pipe.state_derivative_from_ports( - {"port_1": props.h + 1000.0, "port_2": props.h - 1000.0} - ) - - self.assertAlmostEqual(derivative[0], 0.1) - - def test_connection_derivative_uses_energy_balanced_node_enthalpy(self) -> None: - pipe = AmesimPnl0002("pnl_2", self.medium) - props = pipe.properties() - pipe.port_1.m_flow = 0.2 - pipe.port_2.m_flow = 0.1 - reference_h = props.h + 10_000.0 - pipe.update_flow_temperature_references( - {"port_1": reference_h, "port_2": reference_h} - ) - - derivative = pipe.state_derivative_from_ports( - {"port_1": props.h, "port_2": props.h} - ) - - self.assertAlmostEqual(derivative[0], 0.3) - self.assertAlmostEqual(derivative[1], 0.3 * props.h) - - def test_result_cm_averages_bare_parameter_for_each_resistance(self) -> None: - pipe = AmesimPnl0002( - "pnl_2", - self.medium, - diam=0.02, - le=2.0, - rr=0.045 / 20.0, - p0=100000.0, - T0=350.0, - ) - center = pipe.properties() - pipe.port_1.p = 130000.0 - pipe.port_2.p = 80000.0 - pipe.update_flow_temperature_references( - { - "port_1": self.medium.specific_enthalpy_at_pressure( - pipe.port_1.p, - 250.0, - ), - "port_2": self.medium.specific_enthalpy_at_pressure( - pipe.port_2.p, - 450.0, - ), - } - ) - - raw_parameters: list[float] = [] - bare_parameters: list[float] = [] - frictions: list[float] = [] - for port_name, port in ( - ("port_1", pipe.port_1), - ("port_2", pipe.port_2), - ): - flow = pipe.port_mass_flow( - port.p, - center.p, - center.T, - port_name=port_name, - ) - if flow >= 0.0: - upstream_pressure = port.p - upstream_temperature = pipe.medium.temperature_from_pressure_enthalpy( - port.p, - pipe._connected_h[port_name], - ) - else: - upstream_pressure = center.p - upstream_temperature = center.T - reynolds = pipe.reynolds_number(flow, upstream_temperature) - friction = pipe.friction_factor(reynolds) - raw = ( - abs(flow) - * sqrt(upstream_temperature) - / (pipe.area * upstream_pressure) - ) - flow_coefficient = sqrt( - pipe.diam / (pipe.resistance_length * friction) - ) - raw_parameters.append(raw) - bare_parameters.append(raw / flow_coefficient) - frictions.append(friction) - - expected = sum(bare_parameters) / 2.0 - collapsed = (sum(raw_parameters) / 2.0) / sqrt( - pipe.diam - / (pipe.resistance_length * (sum(frictions) / 2.0)) - ) - - actual = pipe.component_result_values()["cm"] - - self.assertAlmostEqual(actual, expected) - self.assertGreater(abs(actual - collapsed), 1.0e-8) class AmesimPnl0003ComponentTests(unittest.TestCase): def setUp(self) -> None: self.medium = IdealGasMedium() - def test_default_create_preserves_contract(self) -> None: - pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl0003"].create("pnl_3", self.medium, {}) - self.assertIsInstance(pipe, AmesimPnl0003) - self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) - self.assertEqual(len(pipe.get_state_vector()), 4) - def test_initial_state_uses_two_half_volume_compliances(self) -> None: - pipe = AmesimPnl0003( - "pnl_3", - self.medium, - diam=0.02, - le=0.3, - rr=0.045 / 20.0, - p1_0=15.3e6, - T1_0=293.15, - p2_0=15.3e6, - T2_0=293.15, - ) - port_1 = pipe.properties_1() - port_2 = pipe.properties_2() - self.assertAlmostEqual(pipe.volume, 9.424777960769381e-5) - self.assertAlmostEqual(pipe.compliance_volume, pipe.volume / 2.0) - self.assertAlmostEqual(port_1.p, 15.3e6, delta=1.0e-5) - self.assertAlmostEqual(port_2.p, 15.3e6, delta=1.0e-5) - def test_center_resistance_flow_follows_end_pressure_gradient(self) -> None: - pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) - forward = pipe.resistance_mass_flow() - pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=100000.0, p2_0=101000.0) - reverse = pipe.resistance_mass_flow() - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None: - base_pressure = 8.0e6 - former_threshold = base_pressure * 1.0e-7 - below = AmesimPnl0003( - "below", - self.medium, - p1_0=base_pressure + 0.99 * former_threshold, - p2_0=base_pressure, - ).resistance_mass_flow() - above = AmesimPnl0003( - "above", - self.medium, - p1_0=base_pressure + 1.01 * former_threshold, - p2_0=base_pressure, - ).resistance_mass_flow() - reverse = AmesimPnl0003( - "reverse", - self.medium, - p1_0=base_pressure, - p2_0=base_pressure + 0.99 * former_threshold, - ).resistance_mass_flow() - equal = AmesimPnl0003( - "equal", - self.medium, - p1_0=base_pressure, - p2_0=base_pressure, - ).resistance_mass_flow() - small = AmesimPnl0003( - "small", - self.medium, - p1_0=15.3e6 + 0.1, - p2_0=15.3e6, - ).resistance_mass_flow() - self.assertGreater(below, 0.0) - self.assertGreater(above, below) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9) - self.assertLess(abs(above - below), abs(above) * 0.05) - self.assertEqual(equal, 0.0) - self.assertGreater(small, 0.0) - def test_center_flow_is_continuous_across_former_relative_deadband(self) -> None: - base_pressure = 8.0e6 - former_threshold = base_pressure * 1.0e-7 - below = AmesimPnl0003( - "below", - self.medium, - p1_0=base_pressure + 0.99 * former_threshold, - p2_0=base_pressure, - ).resistance_mass_flow() - above = AmesimPnl0003( - "above", - self.medium, - p1_0=base_pressure + 1.01 * former_threshold, - p2_0=base_pressure, - ).resistance_mass_flow() - reverse = AmesimPnl0003( - "reverse", - self.medium, - p1_0=base_pressure, - p2_0=base_pressure + 0.99 * former_threshold, - ).resistance_mass_flow() - equal = AmesimPnl0003( - "equal", - self.medium, - p1_0=base_pressure, - p2_0=base_pressure, - ).resistance_mass_flow() - - self.assertGreater(below, 0.0) - self.assertGreater(above, below) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9) - self.assertLess(abs(above - below), abs(above) * 0.05) - self.assertEqual(equal, 0.0) - - def test_exact_repeated_center_flow_inversion_is_cached(self) -> None: - pipe = AmesimPnl0003("pnl_3", self.medium) - pipe._mass_flow_for_pressure_drop.cache_clear() - - first = pipe._mass_flow_for_pressure_drop( - 1000.0, - density=1.2, - temperature=300.0, - ) - after_first = pipe._mass_flow_for_pressure_drop.cache_info() - second = pipe._mass_flow_for_pressure_drop( - 1000.0, - density=1.2, - temperature=300.0, - ) - after_second = pipe._mass_flow_for_pressure_drop.cache_info() - changed_temperature = pipe._mass_flow_for_pressure_drop( - 1000.0, - density=1.2, - temperature=400.0, - ) - after_temperature_change = pipe._mass_flow_for_pressure_drop.cache_info() - - self.assertEqual(first, second) - self.assertEqual(after_first.misses, 1) - self.assertEqual(after_second.hits, after_first.hits + 1) - self.assertNotEqual(changed_temperature, second) - self.assertEqual( - after_temperature_change.misses, - after_second.misses + 1, - ) - - def test_pressure_flow_residuals_bind_both_port_pressures_to_states(self) -> None: - pipe = AmesimPnl0003("pnl_3", self.medium) - pipe.properties_1() - pipe.properties_2() - - residuals = { - residual.id.rsplit(":", 1)[1]: residual - for residual in pipe.pressure_flow_equation_residuals() - } - - self.assertEqual(set(residuals), {"port_1_pressure_state", "port_2_pressure_state"}) - self.assertAlmostEqual(residuals["port_1_pressure_state"].value, 0.0) - self.assertAlmostEqual(residuals["port_2_pressure_state"].value, 0.0) - - def test_connection_derivative_conserves_external_and_center_flows(self) -> None: - pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) - port_1 = pipe.properties_1() - port_2 = pipe.properties_2() - pipe.port_1.m_flow = 0.2 - pipe.port_2.m_flow = -0.1 - - derivative = pipe.state_derivative_from_ports( - {"port_1": port_1.h + 1000.0, "port_2": port_2.h - 1000.0} - ) - - self.assertAlmostEqual(derivative[0] + derivative[2], 0.1) - - def test_results_include_two_thermodynamic_sides_and_center_flow(self) -> None: - pipe = AmesimPnl0003("pnl_3", self.medium, p1_0=101000.0, p2_0=100000.0) - - values = pipe.component_result_values() - - self.assertIn("m1", values) - self.assertIn("m2", values) - self.assertIn("dmctr", values) - self.assertIn("re", values) - - def test_reported_reynolds_uses_amesim_helium_nasa_viscosity_only(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pipe = AmesimPnl0003("pnl_3", medium, diam=0.02) - mass_flow = 0.04555349965141288 - upstream_temperature = 233.92077861710192 - - # Frozen AMESim dense row: t=0.81 s, branch 2, D=0.02 m. - expected_amesim_reynolds = 172298.96343252173 - reported = pipe.reported_reynolds_number( - mass_flow, - upstream_temperature, - ) - dynamic = pipe.reynolds_number(mass_flow, upstream_temperature) - - self.assertAlmostEqual(reported, expected_amesim_reynolds, delta=1.0e-9) - self.assertNotAlmostEqual(reported, dynamic, delta=1.0) - self.assertAlmostEqual( - dynamic, - 4.0 - * abs(mass_flow) - / ( - pi - * pipe.diam - * medium.dynamic_viscosity(upstream_temperature) - ), - ) - - def test_result_cm_excludes_center_resistance_flow_coefficient(self) -> None: - pipe = AmesimPnl0003( - "pnl_3", - self.medium, - diam=0.02, - le=0.3, - rr=0.045 / 20.0, - p1_0=10.7e6, - T1_0=263.4, - p2_0=10.68e6, - T2_0=263.42, - ) - port_1 = pipe.properties_1() - port_2 = pipe.properties_2() - flow = pipe.resistance_mass_flow() - upstream = port_1 if flow >= 0.0 else port_2 - reynolds = pipe.reynolds_number(flow, upstream.T) - friction = pipe.friction_factor(reynolds) - raw_cq_cm = ( - abs(flow) - * sqrt(upstream.T) - / (pipe.area * max(port_1.p, port_2.p)) - ) - flow_coefficient = sqrt(pipe.diam / (pipe.le * friction)) - - actual = pipe.component_result_values()["cm"] - - self.assertAlmostEqual(actual, raw_cq_cm / flow_coefficient) - self.assertNotAlmostEqual(actual, raw_cq_cm) if __name__ == "__main__": diff --git a/tests/test_amesim_pnl00r_component.py b/tests/test_amesim_pnl00r_component.py deleted file mode 100644 index 6360e38..0000000 --- a/tests/test_amesim_pnl00r_component.py +++ /dev/null @@ -1,683 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.components.amesim.flow.pipes import ( - AmesimPnl0001, - AmesimPnl00r, -) -from app.simulation.components.amesim.junctions.nodes import AmesimPn3Node2 -from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.medium import IdealGasMedium -from app.simulation.registry import COMPONENT_MODEL_REGISTRY -from app.simulation.solvers.stream import StreamResolver -from app.simulation.systems.network import SimulationNetwork - - -class AmesimPnl00rComponentTests(unittest.TestCase): - def setUp(self) -> None: - self.medium = IdealGasMedium() - - def test_default_create_preserves_amesim_parameter_contract(self) -> None: - pipe = COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create( - "pnl_1", - self.medium, - {}, - ) - - self.assertIsInstance(pipe, AmesimPnl00r) - self.assertEqual(set(pipe.ports), {"port_1", "port_2"}) - self.assertEqual(set(pipe.parameter_values), {"diam", "le", "rr", "gi"}) - self.assertEqual(pipe.parameter_values["diam"], 0.01) - self.assertEqual(pipe.gi, 0) - - def test_rejects_fractional_gas_index(self) -> None: - with self.assertRaisesRegex(ValueError, "gi must be an integer"): - COMPONENT_MODEL_REGISTRY["amesim_pnl00r"].create( - "pnl_1", - self.medium, - {"gi": 1.5}, - ) - - def test_initial_flow_temperature_reference_preserves_default_behavior(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium) - initial_h = self.medium.specific_enthalpy(self.medium.T_ref) - - self.assertEqual( - pipe._connected_h, - {"port_1": initial_h, "port_2": initial_h}, - ) - self.assertAlmostEqual(pipe._port_temperature("port_1"), self.medium.T_ref) - self.assertAlmostEqual(pipe._port_temperature("port_2"), self.medium.T_ref) - - forward = pipe.mass_flow(501000.0, 500000.0) - reverse = pipe.mass_flow(500000.0, 501000.0) - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02) - - def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium) - pipe.port_1.p = 100000.0 - pipe.port_2.p = 100000.0 - pipe.port_1.m_flow = 0.0 - pipe.port_2.m_flow = 0.0 - - residuals = { - residual.id.rsplit(":", 1)[1]: residual - for residual in pipe.pressure_flow_equation_residuals() - } - - self.assertEqual(pipe.mass_flow(100000.0, 100000.0), 0.0) - self.assertEqual(residuals["mass_flow_balance"].value, 0.0) - self.assertEqual(residuals["pressure_flow_relation"].value, 0.0) - self.assertEqual(set(pipe.component_result_values()), {"re", "cm", "v", "ff"}) - - def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0) - - forward = pipe.mass_flow(501000.0, 500000.0) - reverse = pipe.mass_flow(500000.0, 501000.0) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(forward, -reverse, delta=abs(forward) * 0.02) - - def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium, diam=0.014, le=1.0, rr=0.045 / 14.0) - base_pressure = 8.0e6 - former_threshold = base_pressure * 1.0e-7 - - below = pipe.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure) - above = pipe.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure) - reverse = pipe.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold) - - self.assertGreater(below, 0.0) - self.assertGreater(above, below) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9) - self.assertLess(abs(above - below), abs(above) * 0.10) - self.assertEqual(pipe.mass_flow(base_pressure, base_pressure), 0.0) - self.assertGreater(pipe.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0) - - def test_matches_amesim_pn2pipefr_laminar_baselines(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pipe = AmesimPnl00r( - "pnl_1", - medium, - diam=0.014, - le=1.0, - rr=0.045 / 14.0, - ) - samples = ( - ( - 13_887_929.5734, - 288.959589373, - 13_887_887.6633, - 288.959239289, - 1.79075554200e-4, - 838.404977868, - 3.32483767035e-6, - 0.0521569680484, - 0.076335424633, - ), - ( - 13_093_276.1461, - 285.548411921, - 13_093_238.0341, - 285.546325432, - 1.40922897107e-4, - 665.123640745, - 3.09743309373e-6, - 0.0429212663992, - 0.0962227112065, - ), - ( - 11_690_210.3512, - 272.892693485, - 11_690_180.6138, - 272.889527291, - 7.10081729578e-5, - 345.615999811, - 2.3706370264e-6, - 0.0230685613948, - 0.185176612295, - ), - ) - - for p_1, T_1, p_2, T_2, mass_flow, re, cm, velocity, ff in samples: - with self.subTest(pressure=p_1): - pipe.port_1.p = p_1 - pipe.port_2.p = p_2 - pipe.update_stream_outflows( - { - "port_1": medium.specific_enthalpy_at_pressure(p_1, T_1), - "port_2": medium.specific_enthalpy_at_pressure(p_2, T_2), - } - ) - - actual_mass_flow = pipe.mass_flow(p_1, p_2) - results = pipe.component_result_values() - - self.assertAlmostEqual(actual_mass_flow / mass_flow, 1.0, delta=0.005) - self.assertAlmostEqual(results["re"] / re, 1.0, delta=0.01) - self.assertAlmostEqual(results["cm"] / cm, 1.0, delta=1.0e-4) - self.assertAlmostEqual(results["v"] / velocity, 1.0, delta=0.005) - self.assertAlmostEqual(results["ff"] / ff, 1.0, delta=0.01) - - def test_matches_amesim_pn2pipefr_transition_baselines(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pipe = AmesimPnl00r( - "pnl_1", - medium, - diam=0.014, - le=1.0, - rr=0.045 / 14.0, - ) - # Unchanged test_mql_1.ame, pneumatic_105, ascending transition at - # t=0.2862..0.3047 s. AMESim dm1 is positive into port 1 while this - # component's port-1-to-port-2 coordinate is negative for these rows. - samples = ( - ( - 10_631_986.90667049, - 262.71180639527233, - 10_632_026.310978588, - 262.7092388107747, - -2.8192909824026485e-4, - 1408.0846374820405, - 5.030700878807581e-6, - -0.09668817162408228, - 0.04545209089983021, - ), - ( - 10_594_171.934888396, - 262.32910587014925, - 10_594_213.825687861, - 262.3266782251236, - -3.598950492898518e-4, - 1799.2599253847663, - 5.723572909300966e-6, - -0.1236749841566422, - 0.03590026408283139, - ), - ( - 10_554_721.69137201, - 261.9257072357455, - 10_554_766.280823693, - 261.92350790162175, - -4.3973197060195834e-4, - 2200.69088342098, - 6.529418698258151e-6, - -0.1514267390526478, - 0.03111112083131272, - ), - ( - 10_544_953.846352266, - 261.82512637406904, - 10_544_999.25593169, - 261.82299814935993, - -4.592053720488228e-4, - 2298.7460389094877, - 6.783006948981891e-6, - -0.15821443006863944, - 0.03074226487938153, - ), - ( - 10_514_807.25092133, - 261.5128728347395, - 10_514_855.822238008, - 261.5109955483938, - -5.190048780471613e-4, - 2600.199197308607, - 7.798343300840328e-6, - -0.17910295287093314, - 0.03166638237616583, - ), - ( - 10_432_760.829010766, - 260.6482173603332, - 10_432_818.817992153, - 260.6472539768074, - -6.759582533840764e-4, - 3394.136683451109, - 1.1253055264735043e-5, - -0.23427502517225277, - 0.03839469933871734, - ), - ) - - for p_1, T_1, p_2, T_2, mass_flow, re, cm, velocity, ff in samples: - with self.subTest(reynolds=re): - pipe.port_1.p = p_1 - pipe.port_2.p = p_2 - pipe.update_stream_outflows( - { - "port_1": medium.specific_enthalpy_at_pressure(p_1, T_1), - "port_2": medium.specific_enthalpy_at_pressure(p_2, T_2), - } - ) - - actual_mass_flow = pipe.mass_flow(p_1, p_2) - results = pipe.component_result_values() - - self.assertAlmostEqual(actual_mass_flow / mass_flow, 1.0, delta=0.006) - self.assertAlmostEqual(results["re"] / re, 1.0, delta=0.012) - self.assertAlmostEqual(results["cm"] / cm, 1.0, delta=1.0e-4) - self.assertAlmostEqual(results["v"] / velocity, 1.0, delta=0.006) - self.assertAlmostEqual(results["ff"] / ff, 1.0, delta=0.012) - - def test_upstream_temperature_uses_same_side_connected_stream(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium) - h_1 = self.medium.specific_enthalpy(250.0) - h_2 = self.medium.specific_enthalpy(400.0) - - pipe.update_stream_outflows({"port_1": h_1, "port_2": h_2}) - - self.assertAlmostEqual(pipe._port_temperature("port_1"), 250.0) - self.assertAlmostEqual(pipe._port_temperature("port_2"), 400.0) - self.assertEqual(pipe.port_1.h_outflow, h_2) - self.assertEqual(pipe.port_2.h_outflow, h_1) - - def test_pn2pipefr_cache_keys_all_property_inputs(self) -> None: - pipe = AmesimPnl00r( - "pnl_1", - self.medium, - diam=0.014, - le=1.0, - rr=0.045 / 14.0, - ) - pipe.update_flow_temperature_references( - { - "port_1": self.medium.specific_enthalpy(300.0), - "port_2": self.medium.specific_enthalpy(300.0), - } - ) - AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_clear() - - first = pipe.mass_flow(501000.0, 500000.0) - after_first = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info() - second = pipe.mass_flow(501000.0, 500000.0) - after_second = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info() - pipe.update_flow_temperature_references( - { - "port_1": self.medium.specific_enthalpy(400.0), - "port_2": self.medium.specific_enthalpy(300.0), - } - ) - third = pipe.mass_flow(501000.0, 500000.0) - after_temperature_change = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info() - - self.assertEqual(first, second) - self.assertEqual(after_first.misses, 1) - self.assertEqual(after_second.hits, after_first.hits + 1) - self.assertNotEqual(third, second) - self.assertEqual( - after_temperature_change.misses, - after_second.misses + 1, - ) - - def test_stream_outflows_are_crossed_but_flow_temperature_is_same_side(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium) - hot_h = self.medium.specific_enthalpy(420.0) - cold_h = self.medium.specific_enthalpy(240.0) - - pipe.update_stream_outflows({"port_1": hot_h, "port_2": cold_h}) - - self.assertEqual(pipe.port_1.h_outflow, cold_h) - self.assertEqual(pipe.port_2.h_outflow, hot_h) - self.assertEqual(pipe._connected_h, {"port_1": hot_h, "port_2": cold_h}) - self.assertAlmostEqual(pipe._port_temperature("port_1"), 420.0) - self.assertAlmostEqual(pipe._port_temperature("port_2"), 240.0) - - warmer_h = self.medium.specific_enthalpy(460.0) - pipe.update_flow_temperature_references( - {"port_1": warmer_h, "port_2": cold_h} - ) - - self.assertEqual( - pipe._connected_h, - {"port_1": warmer_h, "port_2": cold_h}, - ) - self.assertAlmostEqual(pipe._port_temperature("port_1"), 460.0) - # Updating the pressure-flow-only reference must not change the - # zero-volume component's already propagated connector outflows. - self.assertEqual(pipe.port_1.h_outflow, cold_h) - self.assertEqual(pipe.port_2.h_outflow, hot_h) - - def test_stream_closure_refreshes_same_side_cache_before_recomputed_flow(self) -> None: - hot_temperature = 420.0 - cold_temperature = 240.0 - source = Cylinder( - "source", - self.medium, - V=0.02, - p0=501000.0, - T0=hot_temperature, - ) - pipe = AmesimPnl00r("pnl_1", self.medium) - sink = Tank( - "sink", - self.medium, - V=0.05, - p0=500000.0, - T0=cold_temperature, - ) - network = SimulationNetwork("pnl00r-stream-refresh") - for component in (source, pipe, sink): - network.add_component(component) - network.connect("source", "port_b", "pnl_1", "port_1") - network.connect("pnl_1", "port_2", "sink", "port_a") - - diagnostics, connected_h = StreamResolver(network).solve() - - self.assertTrue(diagnostics.converged) - self.assertEqual(pipe._connected_h, connected_h["pnl_1"]) - self.assertAlmostEqual(pipe._port_temperature("port_1"), hot_temperature) - self.assertAlmostEqual(pipe._port_temperature("port_2"), cold_temperature) - - # The pressure-flow layer runs again after this stream closure pass. - # Its first recomputation therefore observes the freshly cached source - # enthalpy, independently of the crossed connector outflow values. - pipe.port_1.p = source.port_b.p - pipe.port_2.p = sink.port_a.p - flow = pipe.mass_flow(pipe.port_1.p, pipe.port_2.p) - self.assertGreater(flow, 0.0) - - def test_node_port_2_reference_refresh_does_not_replace_crossed_outflows( - self, - ) -> None: - hot = Cylinder( - "hot", - self.medium, - V=0.1, - p0=100_000.0, - T0=400.0, - ) - cold = Cylinder( - "cold", - self.medium, - V=0.1, - p0=100_000.0, - T0=200.0, - ) - remote = Cylinder( - "remote", - self.medium, - V=0.1, - p0=100_000.0, - T0=300.0, - ) - node = AmesimPn3Node2("node") - pipe = AmesimPnl00r("pnl_1", self.medium) - node.port_1.m_flow = 1.0 - node.port_2.m_flow = -1.0 - node.port_3.m_flow = 0.0 - - network = SimulationNetwork("pnl00r-node-reference") - for component in (hot, cold, remote, node, pipe): - network.add_component(component) - network.connect("hot", "port_b", "node", "port_1") - network.connect("cold", "port_b", "node", "port_3") - network.connect("node", "port_2", "pnl_1", "port_1") - network.connect("pnl_1", "port_2", "remote", "port_b") - resolver = StreamResolver(network) - - diagnostics, connected_h = resolver.solve() - references = resolver.connected_temperature_reference_enthalpies() - outflows_before = (pipe.port_1.h_outflow, pipe.port_2.h_outflow) - - self.assertTrue(diagnostics.converged) - self.assertNotEqual(node.port_2.h_outflow, node.temperature_reference_h) - self.assertEqual(pipe._connected_h, connected_h["pnl_1"]) - self.assertNotEqual( - connected_h["pnl_1"]["port_1"], - references["pnl_1"]["port_1"], - ) - - resolver.refresh_flow_temperature_references() - - self.assertEqual(pipe._connected_h, references["pnl_1"]) - self.assertEqual( - pipe._connected_h["port_1"], - node.temperature_reference_h, - ) - self.assertEqual( - (pipe.port_1.h_outflow, pipe.port_2.h_outflow), - outflows_before, - ) - - def test_hot_and_cold_upstream_flow_results_and_equations_are_consistent(self) -> None: - pipe = AmesimPnl00r( - "pnl_1", - self.medium, - diam=0.014, - le=1.0, - rr=0.045 / 14.0, - ) - hot_temperature = 420.0 - cold_temperature = 240.0 - pipe.update_stream_outflows( - { - "port_1": self.medium.specific_enthalpy(hot_temperature), - "port_2": self.medium.specific_enthalpy(cold_temperature), - } - ) - - reference_hot = AmesimPnl00r( - "reference_hot", - self.medium, - diam=pipe.diam, - le=pipe.le, - rr=pipe.rr, - ) - reference_hot.update_flow_temperature_references( - { - "port_1": self.medium.specific_enthalpy(hot_temperature), - "port_2": self.medium.specific_enthalpy(hot_temperature), - } - ) - reference_cold = AmesimPnl00r( - "reference_cold", - self.medium, - diam=pipe.diam, - le=pipe.le, - rr=pipe.rr, - ) - reference_cold.update_flow_temperature_references( - { - "port_1": self.medium.specific_enthalpy(cold_temperature), - "port_2": self.medium.specific_enthalpy(cold_temperature), - } - ) - - cases = ( - ("forward_hot", 501000.0, 500000.0, hot_temperature, reference_hot), - ("reverse_cold", 500000.0, 501000.0, cold_temperature, reference_cold), - ) - for label, p_1, p_2, upstream_temperature, reference in cases: - with self.subTest(label=label): - pipe.port_1.p = p_1 - pipe.port_2.p = p_2 - reference.port_1.p = p_1 - reference.port_2.p = p_2 - expected_flow = reference.mass_flow(p_1, p_2) - actual_flow = pipe.mass_flow(p_1, p_2) - - self.assertAlmostEqual(actual_flow, expected_flow) - pipe.port_1.m_flow = actual_flow - pipe.port_2.m_flow = -actual_flow - equation_values = pipe.pressure_flow_equation_values() - residual_values = tuple( - residual.value - for residual in pipe.pressure_flow_equation_residuals() - ) - self.assertEqual(equation_values, (0.0, 0.0)) - self.assertEqual(residual_values, equation_values) - - results = pipe.component_result_values() - upstream_pressure = max(p_1, p_2) - density = self.medium.density( - upstream_pressure, - upstream_temperature, - ) - expected_reynolds = pipe.reynolds_number( - actual_flow, - upstream_temperature, - ) - friction = pipe.friction_factor(expected_reynolds) - flow_coefficient = ( - pipe.diam / (pipe.le * friction) - ) ** 0.5 - self.assertAlmostEqual(results["re"], expected_reynolds) - self.assertAlmostEqual( - results["v"], - actual_flow / (density * pipe.area), - ) - self.assertAlmostEqual( - results["cm"], - abs(actual_flow) - * upstream_temperature**0.5 - / (flow_coefficient * pipe.area * upstream_pressure), - ) - self.assertAlmostEqual( - results["ff"], - min(friction, 64_000_000.0), - ) - - def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium, rr=1e-5) - - self.assertGreater(pipe.friction_factor(1.0e-12), 64_000_000.0) - self.assertAlmostEqual(pipe.friction_factor(100.0), 64.0 / 100.0) - self.assertGreater(pipe.friction_factor(100000.0), 0.0) - self.assertLess(pipe.friction_factor(100000.0), 0.1) - - def test_reported_friction_factor_is_bounded_at_tiny_flow(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium) - pipe.mass_flow = lambda _p_1, _p_2: 1.0e-30 - - self.assertEqual(pipe.component_result_values()["ff"], 64_000_000.0) - - def test_friction_factor_matches_amesim_smooth_to_rough_transition(self) -> None: - pipe_20mm = AmesimPnl00r( - "pnl_20mm", - self.medium, - diam=0.02, - rr=0.045 / 20.0, - ) - pipe_14mm = AmesimPnl00r( - "pnl_14mm", - self.medium, - diam=0.014, - rr=0.045 / 14.0, - ) - - self.assertAlmostEqual( - pipe_20mm.friction_factor(56_887.5547), - 0.0215740061043, - delta=1.0e-4, - ) - self.assertAlmostEqual( - pipe_20mm.friction_factor(700_686.41), - 0.02400535718, - delta=8.0e-5, - ) - self.assertAlmostEqual( - pipe_14mm.friction_factor(726_799.66), - 0.02658268645, - delta=8.0e-5, - ) - - def test_friction_factor_matches_amesim_transition_sweep(self) -> None: - pipe = AmesimPnl00r( - "pnl_14mm", - self.medium, - diam=0.014, - rr=0.045 / 14.0, - ) - samples = ( - (1408.0846374820405, 0.04545209089983021), - (1594.9147224775163, 0.04022919421391556), - (1799.2599253847663, 0.03590026408283139), - (2001.1290261016431, 0.03286496043823008), - (2200.69088342098, 0.03111112083131272), - (2298.7460389094877, 0.03074226487938153), - (2396.165041443924, 0.030707477592933765), - (2600.199197308607, 0.03166638237616583), - (2795.6649128003255, 0.03349648426341731), - (2996.892872668051, 0.035593529418766444), - (3204.685791989844, 0.037354280559382634), - (3394.136683451109, 0.03839469933871734), - ) - - for reynolds, expected in samples: - with self.subTest(reynolds=reynolds): - self.assertAlmostEqual( - pipe.friction_factor(reynolds) / expected, - 1.0, - delta=0.0015, - ) - - def test_mass_flow_is_monotone_through_transition(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pipe = AmesimPnl00r( - "pnl_14mm", - medium, - diam=0.014, - le=1.0, - rr=0.045 / 14.0, - ) - mean_pressure = 10.6e6 - temperature = 262.0 - enthalpy = medium.specific_enthalpy_at_pressure(mean_pressure, temperature) - pipe.update_flow_temperature_references( - {"port_1": enthalpy, "port_2": enthalpy} - ) - - forward: list[float] = [] - reverse: list[float] = [] - for index in range(221): - pressure_drop = 25.0 + index * 0.25 - high = mean_pressure + 0.5 * pressure_drop - low = mean_pressure - 0.5 * pressure_drop - forward.append(pipe.mass_flow(high, low)) - reverse.append(-pipe.mass_flow(low, high)) - - self.assertLess(pipe.reynolds_number(forward[0], temperature), 1400.0) - self.assertGreater( - pipe.reynolds_number(forward[-1], temperature), 3400.0 - ) - self.assertTrue(all(left < right for left, right in zip(forward, forward[1:]))) - for forward_flow, reverse_flow in zip(forward, reverse): - self.assertAlmostEqual(forward_flow, reverse_flow, delta=1.0e-12) - - def test_friction_factor_matches_amesim_transition_regime(self) -> None: - pipe = AmesimPnl00r( - "pnl_20mm", - self.medium, - diam=0.02, - rr=0.045 / 20.0, - ) - - self.assertAlmostEqual( - pipe.friction_factor(3_699.80236), - 0.0391257647759, - delta=4.0e-4, - ) - - def test_darcy_pressure_drop_uses_flow_sign(self) -> None: - pipe = AmesimPnl00r("pnl_1", self.medium) - density = self.medium.density(500000.0, 300.0) - - forward = pipe.darcy_pressure_drop(0.01, density=density, temperature=300.0) - reverse = pipe.darcy_pressure_drop(-0.01, density=density, temperature=300.0) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(forward, -reverse) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_pnor001_component.py b/tests/test_amesim_pnor001_component.py index 1d65c71..41e8ef0 100644 --- a/tests/test_amesim_pnor001_component.py +++ b/tests/test_amesim_pnor001_component.py @@ -35,59 +35,9 @@ class AmesimPnor001ComponentTests(unittest.TestCase): {"flowset": 1.5}, ) - def test_zero_pressure_drop_has_zero_flow_and_finite_results(self) -> None: - orifice = AmesimPnor001("pnor_1", self.medium) - orifice.port_1.p = 100000.0 - orifice.port_2.p = 100000.0 - orifice.port_1.m_flow = 0.0 - orifice.port_2.m_flow = 0.0 - residuals = { - residual.id.rsplit(":", 1)[1]: residual - for residual in orifice.pressure_flow_equation_residuals() - } - self.assertEqual(orifice.mass_flow(100000.0, 100000.0), 0.0) - self.assertEqual(residuals["mass_flow_balance"].value, 0.0) - self.assertEqual(residuals["pressure_flow_relation"].value, 0.0) - self.assertEqual(set(orifice.component_result_values()), {"cm", "gasvel"}) - def test_exact_repeated_flow_characteristics_are_cached(self) -> None: - orifice = AmesimPnor001("pnor_1", self.medium) - orifice._one_way_flow_characteristics.cache_clear() - - first = orifice._one_way_flow_characteristics( - upstream_pressure=500000.0, - downstream_pressure=100000.0, - upstream_temperature=293.15, - ) - after_first = orifice._one_way_flow_characteristics.cache_info() - second = orifice._one_way_flow_characteristics( - upstream_pressure=500000.0, - downstream_pressure=100000.0, - upstream_temperature=293.15, - ) - - def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None: - orifice = AmesimPnor001("pnor_1", self.medium) - - forward = orifice.mass_flow(500000.0, 100000.0) - reverse = orifice.mass_flow(100000.0, 500000.0) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(forward, -reverse) - - def test_high_pressure_near_equal_flow_is_continuous(self) -> None: - orifice = AmesimPnor001("pnor_1", self.medium) - orifice.port_1.p = 15.3e6 - orifice.port_2.p = 15.3e6 - - small = orifice.mass_flow(15.3e6 + 0.1, 15.3e6) - larger = orifice.mass_flow(15.3e6 + 1.0, 15.3e6) - - self.assertGreater(small, 0.0) - self.assertGreater(larger, small) def test_cv_and_kv_modes_produce_positive_equivalent_area(self) -> None: cv_orifice = AmesimPnor001( diff --git a/tests/test_amesim_pnpl01_component.py b/tests/test_amesim_pnpl01_component.py index 4207865..1784ce6 100644 --- a/tests/test_amesim_pnpl01_component.py +++ b/tests/test_amesim_pnpl01_component.py @@ -22,23 +22,7 @@ class AmesimPnpl01ComponentTests(unittest.TestCase): self.assertEqual(set(source.ports), {"port_1"}) self.assertEqual(source.parameter_values, {}) - def test_zero_flow_residual_uses_port_mass_flow(self) -> None: - source = AmesimPnpl01("plug_1") - source.port_1.m_flow = 0.125 - residuals = source.pressure_flow_equation_residuals() - - self.assertEqual(len(residuals), 1) - self.assertEqual(residuals[0].id, "plug_1:zero_mass_flow") - self.assertEqual(residuals[0].variables, ("plug_1.port_1.m_flow",)) - self.assertEqual(residuals[0].value, 0.125) - - def test_stream_outflow_tracks_connected_enthalpy_when_available(self) -> None: - source = AmesimPnpl01("plug_1") - - source.update_stream_outflows({"port_1": 123.0}) - - self.assertEqual(source.port_1.h_outflow, 123.0) if __name__ == "__main__": diff --git a/tests/test_amesim_pnrp17_xml.py b/tests/test_amesim_pnrp17_xml.py index c41474f..4dca507 100644 --- a/tests/test_amesim_pnrp17_xml.py +++ b/tests/test_amesim_pnrp17_xml.py @@ -144,39 +144,6 @@ class AmesimPnrp17Tests(unittest.TestCase): ), ) - def test_component_equations_match_pnrp17_geometry_and_signs(self) -> None: - medium = IdealGasMedium() - piston = AmesimPnrp17( - "piston_1", - medium, - gi=0.0, - dp=0.2, - dr=0.1, - x0=0.05, - ) - piston.port_1.p = 300000.0 - piston.port_2.x = piston.port_5.x = 0.3 - piston.port_2.v = piston.port_5.v = 0.4 - piston.port_3.x = piston.port_4.x = 0.1 - piston.port_3.v = piston.port_4.v = -0.2 - pressure_force = piston.pressure_force - piston.port_5.f = 10.0 - piston.port_2.f = -pressure_force - piston.port_5.f - piston.port_4.f = 20.0 - piston.port_3.f = pressure_force - piston.port_4.f - - expected_area = pi * (0.2**2 - 0.1**2) / 4.0 - self.assertAlmostEqual(piston.effective_area, expected_area) - self.assertAlmostEqual(piston.chamber_length, 0.25) - self.assertAlmostEqual(piston.chamber_volume, expected_area * 0.25) - self.assertAlmostEqual(piston.chamber_volume_flow, expected_area * 0.6) - for residual in piston.pressure_flow_equation_residuals(): - self.assertAlmostEqual(residual.value, 0.0, msg=residual.id) - - self.assertEqual( - piston.pneumatic_volume_outputs(), - {"port_1": (piston.chamber_volume, piston.chamber_volume_flow)}, - ) def test_rod_diameter_cannot_exceed_piston_diameter(self) -> None: with self.assertRaisesRegex(ValueError, "dr must not exceed"): @@ -207,11 +174,6 @@ class AmesimPnrp17Tests(unittest.TestCase): self.assertAlmostEqual(chamber_value, 0.01 + piston_value, places=10) self.assertGreater(result["series"]["piston_mass.x"][-1], 0.0) self.assertLess(result["series"]["cylinder_mass.x"][-1], 0.0) - self.assertGreater(result["diagnostics"]["pneumaticVolume"]["propagations"], 0) - self.assertEqual( - result["diagnostics"]["pneumaticVolume"]["last"]["outputPorts"], - ["piston_1.port_1"], - ) if __name__ == "__main__": diff --git a/tests/test_amesim_pnvo001_fixed_component.py b/tests/test_amesim_pnvo001_fixed_component.py index 69ceee5..da7fd4b 100644 --- a/tests/test_amesim_pnvo001_fixed_component.py +++ b/tests/test_amesim_pnvo001_fixed_component.py @@ -14,20 +14,6 @@ class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase): def setUp(self) -> None: self.medium = IdealGasMedium() - def test_default_create_preserves_fixed_opening_contract(self) -> None: - valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001_fixed"].create( - "valve_1", - self.medium, - {}, - ) - - self.assertIsInstance(valve, AmesimPnvo001FixedOpening) - self.assertEqual(set(valve.ports), {"port_2", "port_3"}) - self.assertEqual( - set(valve.parameter_values), - {"cq", "area0", "Cv", "Kv", "gi", "flowset", "opening"}, - ) - self.assertEqual(valve.opening, 1.0) def test_rejects_fractional_flowset(self) -> None: with self.assertRaisesRegex(ValueError, "must be one of"): @@ -37,238 +23,14 @@ class AmesimPnvo001FixedOpeningComponentTests(unittest.TestCase): {"flowset": 1.5}, ) - def test_opening_scales_effective_area(self) -> None: - valve = AmesimPnvo001FixedOpening( - "valve_1", - self.medium, - area0=2.0e-6, - opening=0.25, - ) - - self.assertAlmostEqual(valve.maximum_area, 2.0e-6) - self.assertAlmostEqual(valve.effective_area, 0.5e-6) - self.assertEqual(valve.component_result_values()["xv"], 0.25) - - def test_zero_opening_blocks_flow(self) -> None: - valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.0) - - self.assertEqual(valve.mass_flow(500000.0, 100000.0), 0.0) - - def test_closed_opening_only_zeroes_reported_gas_velocity(self) -> None: - valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.0) - valve.port_2.p = 500000.0 - valve.port_3.p = 100000.0 - - closed = valve.component_result_values() - self.assertGreater(closed["cm"], 0.0) - self.assertEqual(closed["gasvel"], 0.0) - - valve.opening = 7.0e-15 - roundoff_closed = valve.component_result_values() - self.assertEqual(roundoff_closed["gasvel"], 0.0) - self.assertEqual(roundoff_closed["cm"], closed["cm"]) - - valve.opening = 1.0e-9 - genuinely_open = valve.component_result_values() - self.assertNotEqual(genuinely_open["gasvel"], 0.0) - self.assertEqual(genuinely_open["cm"], closed["cm"]) - - def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None: - valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5) - - forward = valve.mass_flow(500000.0, 100000.0) - reverse = valve.mass_flow(100000.0, 500000.0) - - self.assertGreater(forward, 0.0) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(forward, -reverse) - - def test_mass_flow_is_continuous_across_former_relative_deadband(self) -> None: - valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5) - base_pressure = 8.0e6 - former_threshold = base_pressure * 1.0e-7 - - below = valve.mass_flow(base_pressure + 0.99 * former_threshold, base_pressure) - above = valve.mass_flow(base_pressure + 1.01 * former_threshold, base_pressure) - reverse = valve.mass_flow(base_pressure, base_pressure + 0.99 * former_threshold) - - self.assertGreater(below, 0.0) - self.assertGreater(above, below) - self.assertLess(reverse, 0.0) - self.assertAlmostEqual(below, -reverse, delta=abs(below) * 1.0e-9) - self.assertLess(abs(above - below), abs(above) * 0.05) - self.assertEqual(valve.mass_flow(base_pressure, base_pressure), 0.0) - self.assertGreater(valve.mass_flow(15.3e6 + 0.1, 15.3e6), 0.0) - - def test_mass_flow_uses_connected_enthalpy_from_the_upstream_side(self) -> None: - valve = AmesimPnvo001FixedOpening("valve_1", self.medium, opening=0.5) - hot_h = self.medium.specific_enthalpy(600.0) - cold_h = self.medium.specific_enthalpy(200.0) - - valve.update_stream_outflows({"port_2": hot_h, "port_3": cold_h}) - - self.assertEqual(valve.port_2.h_outflow, cold_h) - self.assertEqual(valve.port_3.h_outflow, hot_h) - self.assertAlmostEqual( - valve.mass_flow(500000.0, 100000.0), - valve._one_way_mass_flow( - upstream_pressure=500000.0, - downstream_pressure=100000.0, - upstream_temperature=600.0, - ), - ) - self.assertAlmostEqual( - valve.mass_flow(100000.0, 500000.0), - -valve._one_way_mass_flow( - upstream_pressure=500000.0, - downstream_pressure=100000.0, - upstream_temperature=200.0, - ), - ) - def test_helium_flow_uses_pressure_enthalpy_and_real_gas_factor(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - valve = AmesimPnvo001FixedOpening( - "valve_1", - medium, - cq=0.45, - area0=78.5e-6, - opening=1.0, - ) - upstream_pressure = 15_201_996.778497815 - downstream_pressure = 405_072.8123335606 - upstream_temperature = 292.3997890954483 - valve.port_2.p = upstream_pressure - valve.port_3.p = downstream_pressure - upstream_enthalpy = medium.specific_enthalpy_at_pressure( - upstream_pressure, - upstream_temperature, - ) - valve.update_stream_outflows( - { - "port_2": upstream_enthalpy, - "port_3": medium.specific_enthalpy_at_pressure( - downstream_pressure, - 393.46713105173205, - ), - } - ) - self.assertLess(upstream_enthalpy, 0.0) - self.assertAlmostEqual( - valve._upstream_temperature("port_2"), - upstream_temperature, - delta=1.0e-8, - ) - self.assertAlmostEqual( - valve.mass_flow(upstream_pressure, downstream_pressure), - 0.49551308906777447, - delta=1.0e-9, - ) - results = valve.component_result_values() - self.assertAlmostEqual( - results["cm"], - 0.015778323343746598, - delta=1.0e-11, - ) - self.assertAlmostEqual( - results["gasvel"], - 894.7011595213406, - delta=1.0e-8, - ) - def test_helium_laminar_transition_matches_amesim_baseline(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - valve = AmesimPnvo001FixedOpening( - "valve_1", - medium, - cq=0.45, - area0=78.5e-6, - opening=1.0, - ) - cases = ( - # p_up [PaA], p_down [PaA], T_up [K], dm [g/s], Cm, velocity [m/s] - ( - 10_837_357.913884956, - 10_835_428.362241976, - 255.45507181057303, - 9.770711291393273, - 4.07922685863417e-4, - 13.951480313568323, - ), - ( - 10_735_827.842429036, - 10_735_543.135671863, - 254.49635939397584, - 3.4711877823059916, - 1.4601624857015259e-4, - 4.982778680076626, - ), - ( - 10_703_773.135050302, - 10_703_621.857082237, - 254.1925398502598, - 1.53560321377487, - 6.475023461883112e-5, - 2.208065833961227, - ), - ( - 10_682_359.261700785, - 10_682_301.022976216, - 253.98926908210996, - 0.25626141624058846, - 1.0822847918204546e-5, - 0.36890236539556787, - ), - ( - 10_678_100.351449525, - 10_678_093.704329137, - 253.94881208705195, - 0.0033658306146710985, - 1.421965896377586e-7, - 0.004846389527047451, - ), - ) - for ( - p_up, - p_down, - T_up, - expected_dm, - expected_cm, - expected_velocity, - ) in cases: - with self.subTest(pressure_difference=p_up - p_down): - mass_flow_parameter, gas_velocity = ( - valve._one_way_flow_characteristics( - upstream_pressure=p_up, - downstream_pressure=p_down, - upstream_temperature=T_up, - ) - ) - mass_flow_g_s = ( - valve._one_way_mass_flow( - upstream_pressure=p_up, - downstream_pressure=p_down, - upstream_temperature=T_up, - ) - * 1.0e3 - ) - self.assertAlmostEqual(mass_flow_g_s, expected_dm, delta=1.0e-11) - self.assertAlmostEqual( - mass_flow_parameter, - expected_cm, - delta=1.0e-15, - ) - self.assertAlmostEqual( - gas_velocity, - expected_velocity, - delta=5.0e-10, - ) if __name__ == "__main__": diff --git a/tests/test_amesim_pnvo001_signal_xml.py b/tests/test_amesim_pnvo001_signal_xml.py index eb28648..85adf3e 100644 --- a/tests/test_amesim_pnvo001_signal_xml.py +++ b/tests/test_amesim_pnvo001_signal_xml.py @@ -227,7 +227,6 @@ class AmesimPnvo001SignalXmlTests(unittest.TestCase): self.assertEqual(result["series"]["step_1.out.signal"], [1.0, 1.0, 1.0]) self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0]) self.assertIn("valve_1.xv", result["series"]) - self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0) def test_high_pressure_helium_step_restarts_solver_at_event(self) -> None: xml = build_reactflow_system_xml(high_pressure_helium_step_project()) @@ -235,7 +234,6 @@ class AmesimPnvo001SignalXmlTests(unittest.TestCase): self.assertTrue(result["success"], result["message"]) self.assertEqual(result["simulatedUntil"], 0.042) - self.assertEqual(result["diagnostics"]["signal"]["eventTimes"], [0.04]) times = result["series"]["time"] before_event = times.index(0.038) diff --git a/tests/test_amesim_signal_components.py b/tests/test_amesim_signal_components.py deleted file mode 100644 index 340f56c..0000000 --- a/tests/test_amesim_signal_components.py +++ /dev/null @@ -1,196 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.components.amesim.flow.orifices import AmesimPnvo001SignalOpening -from app.simulation.components.amesim.signals.sources import AmesimStep0, AmesimUd00 -from app.simulation.core.medium import IdealGasMedium -from app.simulation.registry import COMPONENT_MODEL_REGISTRY -from app.simulation.solvers.signal import SignalResolver -from app.simulation.systems.network import SimulationNetwork - - -class AmesimSignalComponentTests(unittest.TestCase): - def setUp(self) -> None: - self.medium = IdealGasMedium() - - def test_step0_output_switches_at_step_time(self) -> None: - step = AmesimStep0("step_1", self.medium, initial=0.2, final=0.8, time=0.5) - - self.assertEqual(step.output_at(0.49), 0.2) - self.assertEqual(step.output_at(0.5), 0.8) - self.assertEqual(step.signal_output_values(0.5), {"out": 0.8}) - self.assertEqual(step.signal_event_times(0.0, 1.0), (0.5,)) - self.assertEqual(step.signal_event_times(0.5, 1.0), ()) - - - def test_ud00_output_interpolates_piecewise_signal(self) -> None: - signal = AmesimUd00( - "piecewise_1", - self.medium, - tstart=0.5, - starts=(0.0, 10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0), - ends=(10.0, 20.0, 30.0, 0.0, 0.0, 0.0, 0.0, 0.0), - durations=(1.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0), - nstages=3, - ) - - self.assertAlmostEqual(signal.output_at(0.0), 0.0) - self.assertAlmostEqual(signal.output_at(1.0), 5.0) - self.assertAlmostEqual(signal.output_at(2.5), 15.0) - self.assertAlmostEqual(signal.output_at(5.0), 35.0) - self.assertEqual(signal.signal_output_values(2.5), {"out": 15.0}) - self.assertEqual(signal.signal_event_times(0.0, 5.0), (0.5, 1.5, 3.5)) - - def test_ud00_can_cycle_active_stages(self) -> None: - signal = AmesimUd00( - "piecewise_1", - self.medium, - starts=(0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - ends=(10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - durations=(1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - nstages=2, - iscyclic=True, - ) - - self.assertAlmostEqual(signal.output_at(0.25), 2.5) - self.assertAlmostEqual(signal.output_at(1.25), 12.5) - self.assertAlmostEqual(signal.output_at(2.25), 2.5) - self.assertEqual(signal.signal_event_times(0.0, 5.0), (1.0, 2.0, 3.0, 4.0)) - - def test_ud00_registry_rejects_fractional_stage_controls(self) -> None: - with self.assertRaisesRegex(ValueError, "must be one of"): - COMPONENT_MODEL_REGISTRY["amesim_ud00"].create( - "piecewise_1", - self.medium, - {"nstages": 1.5}, - ) - for name, value in (("nstages", 9.0), ("iscyclic", 2.0)): - with self.subTest(name=name): - with self.assertRaisesRegex( - ValueError, - "must be (?:one of|at most)", - ): - COMPONENT_MODEL_REGISTRY["amesim_ud00"].create( - "piecewise_1", - self.medium, - {name: value}, - ) - - def test_pnvo001_registry_rejects_invalid_flowset_choice(self) -> None: - with self.assertRaisesRegex(ValueError, "must be one of"): - COMPONENT_MODEL_REGISTRY["amesim_pnvo001"].create( - "valve_1", - self.medium, - {"flowset": 1.5}, - ) - - def test_pnvo001_signal_opening_reads_res_port(self) -> None: - valve = COMPONENT_MODEL_REGISTRY["amesim_pnvo001"].create( - "valve_1", - self.medium, - {"opening0": 0.25}, - ) - - self.assertIsInstance(valve, AmesimPnvo001SignalOpening) - self.assertEqual(set(valve.ports), {"res", "port_2", "port_3"}) - self.assertAlmostEqual(valve.opening, 0.25) - valve.res.signal = 1.5 - self.assertAlmostEqual(valve.opening, 1.0) - valve.res.signal = -0.5 - self.assertAlmostEqual(valve.opening, 0.0) - - def test_pnvo001_exact_repeated_flow_characteristics_are_cached(self) -> None: - valve = AmesimPnvo001SignalOpening("valve_1", self.medium, opening0=0.25) - valve._one_way_flow_characteristics.cache_clear() - - first = valve._one_way_flow_characteristics( - upstream_pressure=501000.0, - downstream_pressure=500000.0, - upstream_temperature=300.0, - ) - after_first = valve._one_way_flow_characteristics.cache_info() - second = valve._one_way_flow_characteristics( - upstream_pressure=501000.0, - downstream_pressure=500000.0, - upstream_temperature=300.0, - ) - after_second = valve._one_way_flow_characteristics.cache_info() - changed_pressure = valve._one_way_flow_characteristics( - upstream_pressure=502000.0, - downstream_pressure=500000.0, - upstream_temperature=300.0, - ) - after_pressure_change = valve._one_way_flow_characteristics.cache_info() - - self.assertEqual(first, second) - self.assertEqual(after_first.misses, 1) - self.assertEqual(after_second.hits, after_first.hits + 1) - self.assertNotEqual(changed_pressure, second) - self.assertEqual( - after_pressure_change.misses, - after_second.misses + 1, - ) - - def test_signal_resolver_propagates_step_to_valve_input(self) -> None: - network = SimulationNetwork("signal-smoke") - step = AmesimStep0("step_1", self.medium, initial=0.0, final=0.75, time=0.1) - valve = AmesimPnvo001SignalOpening("valve_1", self.medium, opening0=0.0) - network.add_component(step) - network.add_component(valve) - network.connect("step_1", "out", "valve_1", "res", connection_id="signal-1") - - resolver = SignalResolver(network) - resolver.solve(0.2) - - self.assertAlmostEqual(step.out.signal, 0.75) - self.assertAlmostEqual(valve.res.signal, 0.75) - self.assertAlmostEqual(valve.opening, 0.75) - self.assertEqual(resolver.event_times(0.0, 0.2), (0.1,)) - - def test_signal_output_can_fan_out_but_input_has_one_driver(self) -> None: - network = SimulationNetwork("signal-cardinality") - step_1 = AmesimStep0( - "step_1", self.medium, initial=0.0, final=0.75, time=0.1 - ) - step_2 = AmesimStep0( - "step_2", self.medium, initial=1.0, final=0.25, time=0.1 - ) - valve_1 = AmesimPnvo001SignalOpening( - "valve_1", self.medium, opening0=0.0 - ) - valve_2 = AmesimPnvo001SignalOpening( - "valve_2", self.medium, opening0=0.0 - ) - for component in (step_1, step_2, valve_1, valve_2): - network.add_component(component) - - network.connect("step_1", "out", "valve_1", "res") - network.connect("step_1", "out", "valve_2", "res") - with self.assertRaisesRegex(ValueError, "already has a driver"): - network.connect("step_2", "out", "valve_1", "res") - - SignalResolver(network).solve(0.2) - self.assertAlmostEqual(valve_1.res.signal, 0.75) - self.assertAlmostEqual(valve_2.res.signal, 0.75) - - def test_signal_resolver_event_times_are_sorted_and_deduplicated(self) -> None: - network = SimulationNetwork("signal-events") - network.add_component( - AmesimStep0("step_2", self.medium, initial=0.0, final=1.0, time=0.2) - ) - network.add_component( - AmesimStep0("step_1", self.medium, initial=1.0, final=0.0, time=0.1) - ) - network.add_component( - AmesimStep0("step_3", self.medium, initial=0.0, final=1.0, time=0.2) - ) - - resolver = SignalResolver(network) - - self.assertEqual(resolver.event_times(0.0, 0.3), (0.1, 0.2)) - self.assertEqual(resolver.event_times(0.2, 0.3), ()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_amesim_ud00_xml.py b/tests/test_amesim_ud00_xml.py index 31298a3..9cfcd7e 100644 --- a/tests/test_amesim_ud00_xml.py +++ b/tests/test_amesim_ud00_xml.py @@ -114,7 +114,6 @@ class AmesimUd00XmlTests(unittest.TestCase): self.assertEqual(result["series"]["piecewise_1.out.signal"], [1.0, 1.0, 1.0]) self.assertEqual(result["series"]["valve_1.res.signal"], [1.0, 1.0, 1.0]) self.assertIn("valve_1.xv", result["series"]) - self.assertGreater(result["diagnostics"]["signal"]["propagations"], 0) if __name__ == "__main__": diff --git a/tests/test_analytic_tangent_primitives.py b/tests/test_analytic_tangent_primitives.py deleted file mode 100644 index d5d9685..0000000 --- a/tests/test_analytic_tangent_primitives.py +++ /dev/null @@ -1,474 +0,0 @@ -from __future__ import annotations - -import unittest -from math import exp - -from app.simulation.components.amesim.flow.pipes import AmesimPnl0001 -from app.simulation.components.amesim.media.mediums import ( - AmesimHeliumPengRobinsonMedium, -) -from app.simulation.components.amesim.mechanical.pistons import AmesimPnrp17 -from app.simulation.components.amesim.mechanical.translational import ( - AmesimLstp00a, - AmesimMecmas21, -) -from app.simulation.components.amesim.storage.chambers import AmesimPnch012 -from app.simulation.core.medium import IdealGasMedium - - -class AnalyticTangentPrimitiveTests(unittest.TestCase): - def assert_tangent_close( - self, - actual: float, - expected: float, - *, - relative: float = 5.0e-5, - absolute: float = 1.0e-8, - ) -> None: - self.assertAlmostEqual( - actual, - expected, - delta=max(absolute, relative * max(abs(actual), abs(expected))), - ) - - def test_peng_robinson_m_u_v_linearization_matches_centered_difference(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pressure = 15.3e6 - temperature = 293.15 - volume = 0.01 - mass = medium.density(pressure, temperature) * volume - energy = mass * medium.specific_internal_energy_at_pressure( - pressure, - temperature, - ) - linearization = medium.linearize_properties_from_mU( - mass, - energy, - volume, - (1.0, 0.0, 0.0), - (0.0, 1.0, 0.0), - (0.0, 0.0, 1.0), - ) - self.assertTrue(linearization.valid, linearization.reason) - - arguments = (mass, energy, volume) - steps = (mass * 1.0e-6, abs(energy) * 1.0e-6, volume * 1.0e-6) - for direction, step in enumerate(steps): - lower = list(arguments) - upper = list(arguments) - lower[direction] -= step - upper[direction] += step - lower_props = medium.properties_from_mU(*lower) - upper_props = medium.properties_from_mU(*upper) - for field in ("p", "T", "rho", "u", "h"): - finite_difference = ( - getattr(upper_props, field) - getattr(lower_props, field) - ) / (2.0 * step) - tangent = getattr(linearization.tangents, field)[direction] - self.assert_tangent_close( - tangent, - finite_difference, - relative=2.0e-4, - absolute=1.0e-6, - ) - - def test_pnrp17_geometry_and_force_tangent_is_exact(self) -> None: - piston = AmesimPnrp17("piston", IdealGasMedium(), dp=0.2, dr=0.01, x0=0.1) - piston.port_4.x = 0.02 - piston.port_5.x = 0.05 - piston.port_4.v = -0.2 - piston.port_5.v = 0.3 - piston.port_1.p = 2.0e5 - result = piston.linearize_geometry_and_force( - (1.0, 0.0), - (0.0, 2.0), - (3.0, 0.0), - (0.0, 4.0), - (5.0, 6.0), - ) - area = piston.effective_area - self.assertTrue(result.valid) - self.assertEqual(result.volume_tangent, (-area, 2.0 * area)) - self.assertEqual(result.volume_flow_tangent, (-3.0 * area, 4.0 * area)) - self.assertEqual(result.pressure_force_tangent, (5.0 * area, 6.0 * area)) - - def test_pnch012_balance_tangent_matches_directional_difference(self) -> None: - chamber = AmesimPnch012( - "chamber", - IdealGasMedium(), - cvol0=0.02, - kth=3.0, - sth=0.4, - p0=2.0e5, - T0=300.0, - ) - chamber.port_1.volume = 0.003 - chamber.port_1.volume_flow = 2.0e-4 - flows = (0.02, -0.01, 0.005, -0.004) - enthalpies = { - "port_1": 330000.0, - "port_2": 310000.0, - "port_3": 320000.0, - "port_4": 300000.0, - } - for port_name, flow in zip( - ("port_1", "port_2", "port_3", "port_4"), - flows, - strict=True, - ): - chamber.get_port(port_name).m_flow = flow - - dm = (1.0e-5,) - dU = (2.0,) - dV = (3.0e-6,) - dVdt = (-4.0e-5,) - dq = { - "port_1": (2.0e-3,), - "port_2": (-1.0e-3,), - "port_3": (3.0e-3,), - "port_4": (-2.0e-3,), - } - dh = { - "port_1": (20.0,), - "port_2": (-10.0,), - "port_3": (30.0,), - "port_4": (-20.0,), - } - result = chamber.linearize_state_derivative( - enthalpies, - state_mass_tangent=dm, - state_energy_tangent=dU, - external_volume_tangent=dV, - external_volume_rate_tangent=dVdt, - port_mass_flow_tangents=dq, - connected_h_tangents=dh, - ) - self.assertTrue(result.valid, result.reason) - - original_state = chamber.get_state_vector() - original_volume = chamber.port_1.volume - original_volume_flow = chamber.port_1.volume_flow - epsilon = 1.0e-4 - - def evaluate(sign: float) -> list[float]: - chamber.set_state_vector( - [ - original_state[0] + sign * epsilon * dm[0], - original_state[1] + sign * epsilon * dU[0], - ] - ) - chamber.port_1.volume = original_volume + sign * epsilon * dV[0] - chamber.port_1.volume_flow = ( - original_volume_flow + sign * epsilon * dVdt[0] - ) - perturbed_h = {} - for port_name in enthalpies: - port = chamber.get_port(port_name) - base_flow = flows[int(port_name[-1]) - 1] - port.m_flow = base_flow + sign * epsilon * dq[port_name][0] - perturbed_h[port_name] = ( - enthalpies[port_name] + sign * epsilon * dh[port_name][0] - ) - return chamber.state_derivative_from_ports(perturbed_h) - - lower = evaluate(-1.0) - upper = evaluate(1.0) - chamber.set_state_vector(original_state) - chamber.port_1.volume = original_volume - chamber.port_1.volume_flow = original_volume_flow - for port_name, flow in zip( - ("port_1", "port_2", "port_3", "port_4"), - flows, - strict=True, - ): - chamber.get_port(port_name).m_flow = flow - for row in range(2): - finite_difference = (upper[row] - lower[row]) / (2.0 * epsilon) - self.assert_tangent_close( - result.tangents[row][0], - finite_difference, - relative=1.0e-5, - absolute=1.0e-6, - ) - - def test_pnl0001_local_flow_slope_and_balance_tangent(self) -> None: - medium = IdealGasMedium() - pipe = AmesimPnl0001( - "pipe", - medium, - diam=0.02, - le=0.5, - kth=2.0, - p0=2.0e5, - T0=300.0, - ) - slope = pipe.linearize_mass_flow(2.2e5, 1.8e5, 300.0) - self.assertTrue(slope.valid, slope.reason) - self.assertGreater(slope.partial_p_1, 0.0) - self.assertLess(slope.partial_p_2, 0.0) - self.assertLess(slope.partial_temperature, 0.0) - boundary = pipe.linearize_mass_flow(2.0e5, 2.0e5, 300.0) - self.assertFalse(boundary.valid) - self.assertEqual(boundary.reason, "flow_direction_boundary") - - pipe.port_1.m_flow = 0.02 - pipe.port_2.m_flow = -0.01 - connected_h = {"port_1": 330000.0, "port_2": 310000.0} - dm = (1.0e-6,) - dU = (0.3,) - dq = {"port_1": (2.0e-3,), "port_2": (-1.0e-3,)} - dh = {"port_1": (20.0,), "port_2": (-10.0,)} - result = pipe.linearize_state_derivative( - connected_h, - state_mass_tangent=dm, - state_energy_tangent=dU, - port_mass_flow_tangents=dq, - connected_h_tangents=dh, - ) - self.assertTrue(result.valid, result.reason) - - original_state = pipe.get_state_vector() - epsilon = 1.0e-4 - - def evaluate(sign: float) -> list[float]: - pipe.set_state_vector( - [ - original_state[0] + sign * epsilon * dm[0], - original_state[1] + sign * epsilon * dU[0], - ] - ) - pipe.port_1.m_flow = 0.02 + sign * epsilon * dq["port_1"][0] - pipe.port_2.m_flow = -0.01 + sign * epsilon * dq["port_2"][0] - return pipe.state_derivative_from_ports( - { - name: value + sign * epsilon * dh[name][0] - for name, value in connected_h.items() - } - ) - - lower = evaluate(-1.0) - upper = evaluate(1.0) - for row in range(2): - finite_difference = (upper[row] - lower[row]) / (2.0 * epsilon) - self.assert_tangent_close( - result.tangents[row][0], - finite_difference, - relative=1.0e-5, - absolute=1.0e-6, - ) - - def test_lstp_fixed_mode_tangent_and_boundary_guard(self) -> None: - contact = AmesimLstp00a( - "contact", - IdealGasMedium(), - gap0=0.001, - kcont=2000.0, - rcont=10.0, - Pdis=0.0005, - ) - contact.port_1.x = 0.002 - contact.port_2.x = 0.0 - contact.port_1.v = 0.3 - contact.port_2.v = -0.1 - result = contact.linearize_contact_force( - (0.4,), - (-0.2,), - (0.3,), - (-0.1,), - ) - self.assertTrue(result.valid, result.reason) - epsilon = 1.0e-7 - originals = ( - contact.port_1.x, - contact.port_2.x, - contact.port_1.v, - contact.port_2.v, - ) - - def evaluate(sign: float) -> float: - contact.port_1.x = originals[0] + sign * epsilon * 0.4 - contact.port_2.x = originals[1] + sign * epsilon * -0.2 - contact.port_1.v = originals[2] + sign * epsilon * 0.3 - contact.port_2.v = originals[3] + sign * epsilon * -0.1 - return contact.contact_force - - finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon) - self.assert_tangent_close(result.force_tangent[0], finite_difference) - - contact.clear_causal_contact() - contact.port_1.x = contact.gap0 - contact.port_2.x = 0.0 - boundary = contact.linearize_contact_force( - (1.0,), - (0.0,), - (0.0,), - (0.0,), - ) - self.assertFalse(boundary.valid) - self.assertEqual(boundary.reason, "contact_mode_boundary") - - def test_lstp_causal_contact_retains_a_differentiable_local_mode(self) -> None: - contact = AmesimLstp00a( - "causal_contact", - IdealGasMedium(), - gap0=0.0, - kcont=3000.0, - rcont=12.0, - Pdis=0.001, - ) - contact.port_1.x = 1000.0 - contact.port_2.x = 1000.0 - contact.port_1.v = 0.2 - contact.port_2.v = -0.1 - penetration = 0.002 - damping_fraction = 1.0 - exp(-penetration / contact.Pdis) - force = ( - contact.kcont * penetration - + damping_fraction * contact.rcont * contact.penetration_velocity - ) - contact.set_causal_contact(penetration=penetration, force=force) - result = contact.linearize_contact_force( - (0.3,), - (-0.2,), - (0.4,), - (-0.1,), - ) - self.assertTrue(result.valid, result.reason) - epsilon = 1.0e-6 - originals = ( - contact.port_1.x, - contact.port_2.x, - contact.port_1.v, - contact.port_2.v, - ) - - def evaluate(sign: float) -> float: - contact.port_1.x = originals[0] + sign * epsilon * 0.3 - contact.port_2.x = originals[1] + sign * epsilon * -0.2 - contact.port_1.v = originals[2] + sign * epsilon * 0.4 - contact.port_2.v = originals[3] + sign * epsilon * -0.1 - return contact.contact_force - - finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon) - self.assert_tangent_close( - result.force_tangent[0], - finite_difference, - relative=2.0e-5, - ) - - def test_mecmas_soft_endstop_tangents_match_centered_difference(self) -> None: - cases = ( - { - "name": "lower", - "x0": -0.02, - "xmin": 0.0, - "xmax": 1.0, - }, - { - "name": "upper", - "x0": 1.02, - "xmin": 0.0, - "xmax": 1.0, - }, - ) - for case in cases: - with self.subTest(side=case["name"]): - mass = AmesimMecmas21( - f"mass_{case['name']}", - IdealGasMedium(), - mass=2.0, - useFriction=1.0, - stoptype=2.0, - discContactOption=1.0, - Kbmin=1000.0, - Dbmin=20.0, - Pdmin=0.1, - Kbmax=1200.0, - Dbmax=30.0, - Pdmax=0.1, - v0=0.3, - x0=case["x0"], - xmin=case["xmin"], - xmax=case["xmax"], - ) - mass.port_1.f = 5.0 - mass.port_2.f = -1.0 - result = mass.linearize_state_derivative( - (0.2,), - (-0.1,), - (-0.25,), - (0.4,), - constraint_mode="free", - ) - self.assertTrue(result.valid, result.reason) - epsilon = 1.0e-7 - original_v = mass.v - original_x = mass.x - - def evaluate(sign: float) -> float: - mass.v = original_v + sign * epsilon * -0.25 - mass.x = original_x + sign * epsilon * 0.4 - mass.port_1.f = 5.0 + sign * epsilon * 0.2 - mass.port_2.f = -1.0 + sign * epsilon * -0.1 - return mass.unconstrained_acceleration() - - finite_difference = ( - evaluate(1.0) - evaluate(-1.0) - ) / (2.0 * epsilon) - self.assert_tangent_close( - result.tangents[0][0], - finite_difference, - relative=1.0e-6, - ) - - def test_mecmas_free_and_fixed_mode_tangents(self) -> None: - mass = AmesimMecmas21( - "mass", - IdealGasMedium(), - mass=2.0, - useFriction=2.0, - rvisc=3.0, - wind=0.5, - fcoul=0.0, - stoptype=4.0, - v0=2.0, - ) - mass.port_1.f = 10.0 - mass.port_2.f = -2.0 - result = mass.linearize_state_derivative( - (0.3,), - (-0.1,), - (0.2,), - (0.4,), - constraint_mode="free", - ) - self.assertTrue(result.valid, result.reason) - epsilon = 1.0e-6 - original_v = mass.v - original_x = mass.x - - def evaluate(sign: float) -> float: - mass.v = original_v + sign * epsilon * 0.2 - mass.x = original_x + sign * epsilon * 0.4 - mass.port_1.f = 10.0 + sign * epsilon * 0.3 - mass.port_2.f = -2.0 + sign * epsilon * -0.1 - return mass.unconstrained_acceleration() - - finite_difference = (evaluate(1.0) - evaluate(-1.0)) / (2.0 * epsilon) - self.assert_tangent_close(result.tangents[0][0], finite_difference) - self.assertEqual(result.tangents[1], (0.2,)) - - mass.set_constraint_motion(0.0, velocity=0.0) - fixed = mass.linearize_state_derivative( - (1.0,), - (1.0,), - (1.0,), - (1.0,), - constraint_mode="lower", - ) - self.assertTrue(fixed.valid, fixed.reason) - self.assertEqual(fixed.tangents, ((0.0,), (0.0,))) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_benchmark_regression.py b/tests/test_benchmark_regression.py deleted file mode 100644 index 7b8be91..0000000 --- a/tests/test_benchmark_regression.py +++ /dev/null @@ -1,949 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -from pathlib import Path -import sys -import tempfile -import textwrap -import unittest -from unittest.mock import patch - -from app.simulation.benchmark_regression import ( - DEFAULT_MANIFEST_PATH, - RegressionCaseRequest, - RegressionManifestError, - derive_simulation_xml, - evaluate_regression_golden, - load_regression_manifest, - load_regression_golden, - main, - run_bounded_child_process, - run_regression_suite, - runtime_snapshot, - source_simulation_config, - summarize_simulation_result, -) - - -PHYSICAL_STATE_V21_REPORT = ( - DEFAULT_MANIFEST_PATH.parent - / "runs" - / "2026-08-18-production-physical-state-v2.1-0.2.json" -) - - -def _completed_result(request: RegressionCaseRequest, *, wall_seconds: float = 0.01): - signal_times = ( - [0.04, 0.8] - if request.stop_time >= 1.0 - else [0.04] if request.stop_time >= 0.04 else [] - ) - return { - "outcome": "completed", - "orchestrationWallSeconds": wall_seconds, - "worker": { - "outcome": "completed", - "wallSeconds": wall_seconds, - "lastSimulatedTime": request.stop_time, - "summary": { - "success": True, - "status": "completed", - "simulatedUntil": request.stop_time, - "seriesHealth": { - "seriesCount": 1, - "scalarCount": 2, - "nonfiniteCount": 0, - "timeStrictlyIncreasing": True, - "timeStart": 0.0, - "timeEnd": request.stop_time, - }, - "checkpoints": [ - { - "requestedTime": checkpoint, - "actualTime": checkpoint, - "available": True, - "stateValues": {"state.placeholder": 0.0}, - } - for checkpoint in request.checkpoint_times - ], - "diagnostics": { - "pressureFlow": {"maxScaledResidual": 1.0e-12}, - }, - "eventTrace": { - "signalEventTimes": signal_times, - "segments": [ - {"startTime": start_time} - for start_time in (0.0, *signal_times) - ], - "mechanicalTransitionTimes": [], - "mechanicalTransitionTimesAvailable": True, - }, - "physicalContract": { - "schemaVersion": 1, - "projectionCategories": ["state"], - "checkpoints": [ - { - "requestedTime": checkpoint, - "actualTime": checkpoint, - "available": True, - "stateValues": {"state.placeholder": 0.0}, - } - for checkpoint in request.checkpoint_times - ], - "eventTrace": { - "signalEventTimes": signal_times, - "segments": [ - {"startTime": start_time} - for start_time in (0.0, *signal_times) - ], - "mechanicalTransitionTimes": [], - "mechanicalTransitionTimesAvailable": True, - }, - }, - "outputContract": { - "schemaVersion": 1, - "sha256": "0" * 64, - "variableCount": 1, - "seriesKeyCount": 1, - "sampleCount": 2, - }, - }, - }, - } - - -class RegressionManifestTests(unittest.TestCase): - def test_runtime_snapshot_records_repository_identity(self) -> None: - repository = runtime_snapshot()["repository"] - - if repository["head"] is not None: - self.assertEqual(len(repository["head"]), 40) - self.assertIn(repository["dirty"], (True, False, None)) - self.assertIsInstance(repository["status"], list) - - def test_manifest_locks_authoritative_source_and_two_sampling_lanes(self) -> None: - manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH) - - self.assertEqual( - manifest["sequence"], - ["0.01s-smoke", "0.2s", "1s", "5s", "10s"], - ) - self.assertTrue(manifest["source"]["xmlIsAuthoritative"]) - self.assertEqual( - manifest["source"]["sha256"], - "0a2d9331df9eb5974daec25a61c1238ba32b1742d933ffc8b16ce316c5627b0b", - ) - self.assertEqual(Path(manifest["_sourcePath"]).name, "test-mql-8.xml") - self.assertEqual( - manifest["source"]["companionProject"]["sha256"], - "b44bf540ccd1c293fe2af2b9b9052b540abf83961ad955a0f6a4ab40fbe0bb18", - ) - self.assertFalse( - manifest["source"]["companionProject"]["executionInput"] - ) - self.assertEqual( - manifest["source"]["referenceArchive"]["role"], - "authoritativePhysicalBaseline", - ) - self.assertEqual( - Path(manifest["_referenceArchivePath"]).name, - "test_mql.ame", - ) - self.assertEqual(manifest["historicalReports"][0]["status"], "historicalOnly") - self.assertFalse( - manifest["historicalReports"][0]["compatibleWithCurrentSource"] - ) - self.assertEqual(manifest["lanes"]["production"]["samplingMode"], "source") - self.assertEqual(manifest["lanes"]["solver-only"]["sampleStep"], 0.02) - self.assertEqual(manifest["lanes"]["solver-only"]["maxStep"], 0.05) - self.assertEqual(manifest["historicalReports"][2]["status"], "historicalOnly") - self.assertFalse( - manifest["historicalReports"][2]["compatibleWithCurrentSource"] - ) - physical_state_golden = manifest["_physicalStateV21Goldens"]["0.2s"][ - "production" - ] - self.assertEqual(physical_state_golden["approval"]["status"], "approved") - self.assertEqual( - physical_state_golden["_sha256"], - "6f0752afe8c1f7690599c2709a76d9a1342f99f726c6c8593321dc6267e175b2", - ) - self.assertTrue( - physical_state_golden["amesimAlignmentAtGeneration"]["passed"] - ) - self.assertEqual( - manifest["correctness"]["physicalBaselineAuthority"], "amesim" - ) - self.assertTrue(manifest["correctness"]["compareAmesimOnEveryRun"]) - self.assertEqual( - manifest["correctness"]["pythonGoldenRole"], - "determinismDiagnosticOnly", - ) - self.assertEqual( - manifest["variants"]["0.2s"]["goldens"]["production"]["role"], - "pythonDeterminismRegression", - ) - self.assertEqual( - manifest["variants"]["0.2s"]["physicalStateV21Goldens"][ - "production" - ]["role"], - "amesimPhysicalBaseline", - ) - - def test_manifest_rejects_physical_state_v21_golden_hash_mismatch(self) -> None: - manifest = json.loads(DEFAULT_MANIFEST_PATH.read_text(encoding="utf-8")) - manifest["variants"]["0.2s"]["physicalStateV21Goldens"]["production"][ - "sha256" - ] = "0" * 64 - with tempfile.TemporaryDirectory( - dir=DEFAULT_MANIFEST_PATH.parents[4], prefix=".v21-manifest-test-" - ) as temporary_directory: - manifest_path = Path(temporary_directory) / "manifest.json" - manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - with self.assertRaisesRegex( - RegressionManifestError, - "physical-state-v2.1 golden hash mismatch", - ): - load_regression_manifest(manifest_path) - - def test_manifest_rejects_amesim_archive_hash_mismatch(self) -> None: - manifest = json.loads(DEFAULT_MANIFEST_PATH.read_text(encoding="utf-8")) - manifest["source"]["referenceArchive"]["sha256"] = "0" * 64 - with tempfile.TemporaryDirectory( - dir=DEFAULT_MANIFEST_PATH.parents[4], prefix=".amesim-manifest-test-" - ) as temporary_directory: - manifest_path = Path(temporary_directory) / "manifest.json" - manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - with self.assertRaisesRegex( - RegressionManifestError, - "AMESim reference archive hash mismatch", - ): - load_regression_manifest(manifest_path) - - def test_manifest_rejects_amesim_golden_provenance_mismatch(self) -> None: - loaded = load_regression_manifest(DEFAULT_MANIFEST_PATH) - golden = json.loads( - json.dumps(loaded["_physicalStateV21Goldens"]["0.2s"]["production"]) - ) - golden["provenance"]["amesim"]["archiveSha256"] = "0" * 64 - manifest = json.loads(DEFAULT_MANIFEST_PATH.read_text(encoding="utf-8")) - with tempfile.TemporaryDirectory( - dir=DEFAULT_MANIFEST_PATH.parents[4], prefix=".amesim-provenance-test-" - ) as temporary_directory: - manifest_path = Path(temporary_directory) / "manifest.json" - manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - with patch( - "app.simulation.benchmark_regression.load_physical_state_v21_golden", - return_value=golden, - ), self.assertRaisesRegex( - RegressionManifestError, - "AMESim baseline provenance", - ): - load_regression_manifest(manifest_path) - - def test_in_memory_derivative_does_not_change_authoritative_xml(self) -> None: - manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH) - source_path = Path(manifest["_sourcePath"]) - before = source_path.read_bytes() - - derived = derive_simulation_xml( - before, - stop_time=1.0, - sample_step=0.02, - max_step=0.005, - ) - - self.assertEqual(source_simulation_config(before)["tStop"], 10.0) - self.assertEqual(source_simulation_config(before)["sampleStep"], 0.01) - self.assertEqual(source_simulation_config(before)["maxStep"], 0.001) - self.assertEqual(source_simulation_config(derived)["tStop"], 1.0) - self.assertEqual(source_simulation_config(derived)["sampleStep"], 0.02) - self.assertEqual(source_simulation_config(derived)["maxStep"], 0.005) - self.assertEqual(source_path.read_bytes(), before) - - def test_historical_pure_xml_manifest_does_not_require_companion(self) -> None: - historical_manifest_path = ( - DEFAULT_MANIFEST_PATH.parent.parent - / "test_mql_full_branches" - / "manifest.json" - ) - - manifest = load_regression_manifest(historical_manifest_path) - - self.assertNotIn("companionProject", manifest["source"]) - self.assertIsNone(manifest["_companionPath"]) - - -class RegressionGoldenProtocolTests(unittest.TestCase): - def test_reviewed_golden_is_provenanced_and_compared_numerically(self) -> None: - source_xml_sha256 = "1" * 64 - output_contract_sha256 = "2" * 64 - generated_at = "2026-08-17T00:00:00+00:00" - checkpoints = [{ - "requestedTime": 0.0, - "actualTime": 0.0, - "available": True, - "stateValues": {"state.a": 1.0}, - }] - summary = { - "physicalContract": { - "schemaVersion": 1, - "projectionCategories": ["state"], - "checkpoints": checkpoints, - "eventTrace": {}, - }, - "outputContract": { - "schemaVersion": 1, - "sha256": output_contract_sha256, - }, - } - report = { - "generatedAt": generated_at, - "source": {"sha256": source_xml_sha256}, - "cases": [{ - "caseId": "smoke", - "lane": "solver-only", - "worker": {"summary": summary}, - }], - } - - with tempfile.TemporaryDirectory() as temporary_directory: - repository_root = Path(temporary_directory) - report_path = repository_root / "source-report.json" - report_path.write_text( - json.dumps(report, sort_keys=True), encoding="utf-8" - ) - report_sha256 = hashlib.sha256(report_path.read_bytes()).hexdigest() - state_keys = ["state.a"] - state_layout_sha256 = hashlib.sha256( - json.dumps( - state_keys, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - ).hexdigest() - golden = { - "schemaVersion": 1, - "id": "synthetic-reviewed-golden", - "caseId": "smoke", - "lane": "solver-only", - "sourceXmlSha256": source_xml_sha256, - "approval": {"status": "approved"}, - "provenance": { - "sourceReport": { - "path": "source-report.json", - "sha256": report_sha256, - "generatedAt": generated_at, - "metadataCompatibility": { - "status": "current", - "differences": [], - }, - } - }, - "physicalLayout": { - "projectionCategories": ["state"], - "stateKeys": state_keys, - "stateKeyLayoutSha256": state_layout_sha256, - }, - "tolerance": { - "relative": 0.001, - "absolute": 0.01, - "checkpointTimeAbsoluteSeconds": 1.0e-12, - }, - "physicalCheckpoints": [{ - "requestedTime": 0.0, - "values": [1.0], - }], - "outputContract": {"sha256": output_contract_sha256}, - } - golden_path = repository_root / "golden.json" - golden_path.write_text( - json.dumps(golden, sort_keys=True), encoding="utf-8" - ) - golden_sha256 = hashlib.sha256(golden_path.read_bytes()).hexdigest() - - loaded = load_regression_golden( - golden_path, - expected_sha256=golden_sha256, - repository_root=repository_root, - ) - - passing = evaluate_regression_golden(summary, loaded) - self.assertTrue(passing["passed"]) - self.assertEqual(passing["comparedValueCount"], 1) - self.assertEqual(passing["issues"], []) - - changed_physical = json.loads(json.dumps(summary)) - changed_physical["physicalContract"]["checkpoints"][0][ - "stateValues" - ]["state.a"] = 1.02 - physical_failure = evaluate_regression_golden(changed_physical, loaded) - self.assertFalse(physical_failure["passed"]) - self.assertIn( - "stateCheckpointGoldenValueMismatch", physical_failure["issues"] - ) - - changed_output = json.loads(json.dumps(summary)) - changed_output["outputContract"]["sha256"] = "3" * 64 - output_failure = evaluate_regression_golden(changed_output, loaded) - self.assertFalse(output_failure["passed"]) - self.assertIn("outputContractMismatch", output_failure["issues"]) - - -class BoundedChildProcessTests(unittest.TestCase): - def test_short_json_worker_completes_without_timeout(self) -> None: - script = textwrap.dedent( - """ - import json - print(json.dumps({"event": "progress", "simulatedTime": 0.1}), flush=True) - print(json.dumps({ - "event": "result", - "outcome": "completed", - "lastSimulatedTime": 0.2, - "wallSeconds": 0.01, - }), flush=True) - """ - ) - - result = run_bounded_child_process( - [sys.executable, "-u", "-c", script], - soft_timeout_seconds=1.0, - hard_timeout_seconds=2.0, - termination_grace_seconds=0.2, - ) - - self.assertEqual(result["outcome"], "completed") - self.assertFalse(result["softCancelSent"]) - self.assertFalse(result["hardTimeoutReached"]) - self.assertEqual(result["lastSimulatedTime"], 0.2) - - def test_parent_requests_soft_cancel_before_hard_timeout(self) -> None: - script = textwrap.dedent( - """ - import json - import sys - print(json.dumps({"event": "progress", "simulatedTime": 0.125}), flush=True) - for line in sys.stdin: - if line.strip() == "cancel": - print(json.dumps({ - "event": "result", - "outcome": "cancelled", - "lastSimulatedTime": 0.125, - "wallSeconds": 0.1, - }), flush=True) - break - """ - ) - - result = run_bounded_child_process( - [sys.executable, "-u", "-c", script], - soft_timeout_seconds=0.08, - hard_timeout_seconds=1.0, - termination_grace_seconds=0.2, - ) - - self.assertEqual(result["outcome"], "soft_timeout") - self.assertTrue(result["softCancelSent"]) - self.assertFalse(result["hardTimeoutReached"]) - self.assertEqual(result["lastSimulatedTime"], 0.125) - - def test_unresponsive_child_is_terminated_at_hard_timeout(self) -> None: - script = textwrap.dedent( - """ - import json - import time - print(json.dumps({"event": "progress", "simulatedTime": 0.05}), flush=True) - time.sleep(5) - """ - ) - - result = run_bounded_child_process( - [sys.executable, "-u", "-c", script], - soft_timeout_seconds=0.05, - hard_timeout_seconds=0.15, - termination_grace_seconds=0.1, - ) - - self.assertEqual(result["outcome"], "hard_timeout") - self.assertTrue(result["softCancelSent"]) - self.assertTrue(result["hardTimeoutReached"]) - self.assertEqual(result["lastSimulatedTime"], 0.05) - - def test_child_that_closes_stdin_does_not_break_pipe_cleanup(self) -> None: - script = textwrap.dedent( - """ - import os - import time - os.close(0) - time.sleep(5) - """ - ) - - result = run_bounded_child_process( - [sys.executable, "-u", "-c", script], - soft_timeout_seconds=0.05, - hard_timeout_seconds=0.15, - termination_grace_seconds=0.1, - ) - - self.assertEqual(result["outcome"], "hard_timeout") - self.assertTrue(result["softCancelSent"]) - self.assertTrue(result["hardTimeoutReached"]) - - -class ProgressiveSuiteTests(unittest.TestCase): - @staticmethod - def _physical_state_v21_result_executor(): - source_report = json.loads( - PHYSICAL_STATE_V21_REPORT.read_text(encoding="utf-8") - ) - results = {case["caseId"]: case for case in source_report["cases"]} - - def execute(request: RegressionCaseRequest) -> dict[str, object]: - return json.loads(json.dumps(results[request.case_id])) - - return execute - - def test_production_report_replays_through_active_v21_acceptance(self) -> None: - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="production", - case_ids=["0.2s"], - case_executor=self._physical_state_v21_result_executor(), - ) - - case = report["cases"][1] - evaluation = case["acceptance"]["physicalStateV21Golden"] - self.assertEqual(case["outcome"], "completed") - self.assertTrue(case["acceptance"]["passed"]) - self.assertTrue(evaluation["configured"]) - self.assertTrue(evaluation["evaluated"]) - self.assertTrue(evaluation["passed"]) - self.assertEqual(evaluation["baselineAuthority"], "amesim") - self.assertEqual(evaluation["metricCount"], 33) - self.assertEqual(evaluation["availableBaselineValueCount"], 27) - self.assertEqual(evaluation["unavailableBaselineValueCount"], 6) - self.assertEqual(evaluation["comparedValueCount"], 25) - self.assertEqual(evaluation["excludedValueCount"], 2) - self.assertAlmostEqual( - evaluation["maxToleranceRatio"], 0.6967416724836332 - ) - self.assertAlmostEqual( - evaluation["worstValue"]["relativeError"], - 0.0013934852135053246, - ) - self.assertTrue( - all( - "actual" in metric and "amesimBaseline" in metric - for metric in evaluation["metrics"] - ) - ) - self.assertEqual(evaluation["issues"], []) - self.assertTrue(evaluation["amesimAlignmentAtGenerationPassed"]) - regression = case["acceptance"]["regressionGolden"] - self.assertEqual(regression["role"], "determinismDiagnosticOnly") - self.assertFalse(regression["affectsPhysicalCorrectness"]) - - def test_v21_conservation_drift_fails_runner_acceptance(self) -> None: - execute_source = self._physical_state_v21_result_executor() - - def drifted(request: RegressionCaseRequest) -> dict[str, object]: - result = execute_source(request) - if request.case_id == "0.2s": - result["worker"]["summary"]["physicalStateV21"]["checkpoints"][-1][ - "values" - ]["conservation.p4node2_8.massBalance"] = 2.0e-9 - return result - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="production", - case_ids=["0.2s"], - case_executor=drifted, - ) - - case = report["cases"][1] - evaluation = case["acceptance"]["physicalStateV21Golden"] - self.assertEqual(case["outcome"], "correctness_failed") - self.assertFalse(case["acceptance"]["passed"]) - self.assertIn( - "physicalStateV21LocalInvariantMismatch", - case["acceptance"]["issues"], - ) - self.assertFalse(evaluation["passed"]) - self.assertEqual( - evaluation["localInvariants"]["worstValue"]["key"], - "conservation.p4node2_8.massBalance", - ) - - def test_python_state_drift_is_diagnostic_when_amesim_gate_passes(self) -> None: - execute_source = self._physical_state_v21_result_executor() - - def drifted(request: RegressionCaseRequest) -> dict[str, object]: - result = execute_source(request) - if request.case_id == "0.2s": - values = result["worker"]["summary"]["physicalContract"][ - "checkpoints" - ][-1]["stateValues"] - first_key = next(iter(values)) - values[first_key] += 1.0 - return result - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="production", - case_ids=["0.2s"], - case_executor=drifted, - ) - - case = report["cases"][1] - diagnostic = case["acceptance"]["regressionGolden"] - self.assertEqual(case["outcome"], "completed") - self.assertTrue(case["acceptance"]["passed"]) - self.assertFalse(diagnostic["passed"]) - self.assertIn("stateCheckpointGoldenValueMismatch", diagnostic["issues"]) - self.assertEqual(diagnostic["acceptanceIssues"], []) - self.assertTrue(case["acceptance"]["physicalStateV21Golden"]["passed"]) - - def test_amesim_baseline_drift_is_the_physical_acceptance_gate(self) -> None: - execute_source = self._physical_state_v21_result_executor() - - def drifted(request: RegressionCaseRequest) -> dict[str, object]: - result = execute_source(request) - if request.case_id == "0.2s": - result["worker"]["summary"]["physicalStateV21"]["checkpoints"][-1][ - "values" - ]["pressure.pnch012_8.absolute"] += 1.0e6 - return result - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="production", - case_ids=["0.2s"], - case_executor=drifted, - ) - - case = report["cases"][1] - evaluation = case["acceptance"]["physicalStateV21Golden"] - self.assertEqual(case["outcome"], "correctness_failed") - self.assertFalse(case["acceptance"]["passed"]) - self.assertIn("amesimReferenceToleranceMismatch", case["acceptance"]["issues"]) - self.assertFalse(evaluation["passed"]) - self.assertEqual(evaluation["worstValue"]["amesimBaseline"], 3625053.2009413484) - - def test_requesting_late_case_also_runs_every_predecessor(self) -> None: - calls: list[str] = [] - - def complete(request: RegressionCaseRequest) -> dict[str, object]: - calls.append(request.case_id) - return _completed_result(request) - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_ids=["5s"], - case_executor=complete, - ) - - self.assertEqual(calls, ["0.01s-smoke", "0.2s", "1s", "5s"]) - self.assertEqual( - [case["outcome"] for case in report["cases"]], - ["completed", "completed", "completed", "completed"], - ) - - def test_manifest_pins_solver_modes_in_every_request(self) -> None: - seen: list[dict[str, str]] = [] - - def complete(request: RegressionCaseRequest) -> dict[str, object]: - seen.append(dict(request.environment_overrides)) - return _completed_result(request) - - run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_ids=["0.01s-smoke"], - case_executor=complete, - ) - - self.assertEqual( - seen, - [{ - "SIMULATION_CAUSAL_EXECUTOR_V2": "1", - "SIMULATION_CAUSAL_COORDINATE_KERNEL": "1", - "SIMULATION_CAUSAL_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", - }], - ) - - def test_empty_explicit_case_list_is_rejected(self) -> None: - with self.assertRaisesRegex( - RegressionManifestError, - "No regression variants were selected", - ): - run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_ids=[], - case_executor=_completed_result, - ) - - def test_missing_signal_segment_fails_acceptance(self) -> None: - def missing_segment(request: RegressionCaseRequest) -> dict[str, object]: - result = _completed_result(request) - result["worker"]["summary"]["eventTrace"]["segments"] = [ - {"startTime": 0.0} - ] - return result - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_ids=["0.2s"], - case_executor=missing_segment, - ) - - self.assertEqual(report["cases"][1]["outcome"], "correctness_failed") - self.assertIn( - "signalEventSegmentMissing", - report["cases"][1]["acceptance"]["issues"], - ) - - def test_deferred_suite_returns_nonzero_incomplete_exit_status(self) -> None: - report = {"cases": [ - {"outcome": "completed"}, - {"outcome": "deferred"}, - ]} - with patch( - "app.simulation.benchmark_regression.run_regression_suite", - return_value=report, - ), patch("builtins.print"): - exit_code = main([]) - - self.assertEqual(exit_code, 2) - - def test_nonfinite_completed_case_fails_acceptance_and_defers_longer_runs( - self, - ) -> None: - calls: list[str] = [] - - def nonfinite_first(request: RegressionCaseRequest) -> dict[str, object]: - calls.append(request.case_id) - result = _completed_result(request) - result["worker"]["summary"]["seriesHealth"]["nonfiniteCount"] = 1 - return result - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_executor=nonfinite_first, - ) - - self.assertEqual(calls, ["0.01s-smoke"]) - self.assertEqual(report["cases"][0]["outcome"], "correctness_failed") - self.assertEqual( - report["cases"][0]["acceptance"]["issues"], - ["nonfiniteSeries"], - ) - self.assertTrue( - all(case["outcome"] == "deferred" for case in report["cases"][1:]) - ) - - def test_failed_predecessor_defers_every_longer_horizon(self) -> None: - calls: list[str] = [] - - def fail_first(request: RegressionCaseRequest) -> dict[str, object]: - calls.append(request.case_id) - return {"outcome": "error", "worker": None} - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_executor=fail_first, - ) - - self.assertEqual(calls, ["0.01s-smoke"]) - self.assertEqual(report["cases"][0]["outcome"], "error") - self.assertEqual( - [case["outcome"] for case in report["cases"][1:]], - ["deferred", "deferred", "deferred", "deferred"], - ) - self.assertTrue( - all( - case["reason"] == "predecessorDidNotComplete" - for case in report["cases"][1:] - ) - ) - - def test_prediction_over_budget_defers_before_launching_next_case(self) -> None: - calls: list[str] = [] - - def expensive_short_case(request: RegressionCaseRequest) -> dict[str, object]: - calls.append(request.case_id) - return _completed_result(request, wall_seconds=200.0) - - report = run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_executor=expensive_short_case, - ) - - self.assertEqual(calls, ["0.01s-smoke", "0.2s"]) - self.assertIsNone(report["cases"][1]["predictedWallSeconds"]) - third = report["cases"][2] - self.assertEqual(third["outcome"], "deferred") - self.assertEqual(third["reason"], "predictedWallExceedsSoftBudget") - self.assertEqual(third["predictedWallSeconds"], 1500.0) - - def test_lanes_pass_distinct_sampling_to_case_executor(self) -> None: - seen: list[tuple[str, float, float]] = [] - - def complete(request: RegressionCaseRequest) -> dict[str, object]: - seen.append((request.lane, request.sample_step, request.max_step)) - return _completed_result(request) - - run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="solver-only", - case_ids=["0.01s-smoke"], - case_executor=complete, - ) - run_regression_suite( - DEFAULT_MANIFEST_PATH, - lane="production", - case_ids=["0.01s-smoke"], - case_executor=complete, - ) - - self.assertEqual( - seen, - [("solver-only", 0.02, 0.05), ("production", 0.01, 0.001)], - ) - - -class ResultSummaryTests(unittest.TestCase): - def test_summary_preserves_diagnostics_and_available_event_trace(self) -> None: - result = { - "success": True, - "status": "completed", - "partial": False, - "message": "done", - "simulatedUntil": 0.2, - "requestedStopTime": 0.2, - "variables": [ - {"key": "tank.m", "category": "state"}, - {"key": "tank.p", "category": "thermodynamic"}, - ], - "series": { - "time": [0.0, 0.2], - "tank.m": [1.0, 0.9], - "tank.p": [2.0, 1.5], - }, - "final": {"tank.m": 0.9, "tank.p": 1.5}, - "diagnostics": { - "stateCount": 1, - "signal": {"eventTimes": [0.04]}, - "integration": { - "totals": { - "stateTransitionCount": 2, - "solverStartCount": 3, - }, - "segments": [ - { - "startTime": 0.0, - "requestedStopTime": 0.2, - "simulatedUntil": 0.2, - "stateTransitionCount": 2, - "stateTransitionTimes": [0.11, 0.17], - "solverStartCount": 3, - "recoverableRetryCount": 0, - } - ], - }, - }, - } - - summary = summarize_simulation_result( - result, - checkpoint_times=(0.0, 0.2), - sample_step=0.2, - ) - - self.assertEqual(summary["diagnostics"], result["diagnostics"]) - self.assertEqual(summary["eventTrace"]["signalEventTimes"], [0.04]) - self.assertEqual(summary["eventTrace"]["stateTransitionCount"], 2) - self.assertTrue( - summary["eventTrace"]["mechanicalTransitionTimesAvailable"] - ) - self.assertEqual( - summary["eventTrace"]["mechanicalTransitionTimes"], - [0.11, 0.17], - ) - self.assertEqual( - summary["checkpoints"][1]["stateValues"], {"tank.m": 0.9} - ) - self.assertEqual( - summary["physicalContract"]["checkpoints"], summary["checkpoints"] - ) - self.assertEqual( - summary["physicalContract"]["eventTrace"], summary["eventTrace"] - ) - self.assertFalse(summary["outputContract"]["containsPhysicalValues"]) - self.assertEqual(summary["seriesHealth"]["nonfiniteCount"], 0) - - changed_values = json.loads(json.dumps(result)) - changed_values["series"]["tank.m"] = [99.0, -99.0] - changed_values["series"]["tank.p"] = [-2.0, 1000.0] - value_changed_summary = summarize_simulation_result( - changed_values, - checkpoint_times=(0.0, 0.2), - sample_step=0.2, - ) - self.assertEqual( - value_changed_summary["outputContract"]["sha256"], - summary["outputContract"]["sha256"], - ) - - changed_metadata = json.loads(json.dumps(result)) - changed_metadata["variables"][0]["unit"] = "kg" - metadata_changed_summary = summarize_simulation_result( - changed_metadata, - checkpoint_times=(0.0, 0.2), - sample_step=0.2, - ) - self.assertNotEqual( - metadata_changed_summary["outputContract"]["sha256"], - summary["outputContract"]["sha256"], - ) - - def test_missing_mechanical_transition_times_are_reported_unavailable( - self, - ) -> None: - summary = summarize_simulation_result( - { - "diagnostics": { - "integration": { - "totals": {"stateTransitionCount": 1}, - "segments": [{ - "startTime": 0.0, - "stateTransitionCount": 1, - }], - }, - }, - }, - checkpoint_times=(), - sample_step=0.1, - ) - - self.assertFalse( - summary["eventTrace"]["mechanicalTransitionTimesAvailable"] - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_causal_numeric_ir.py b/tests/test_causal_numeric_ir.py deleted file mode 100644 index 7a04902..0000000 --- a/tests/test_causal_numeric_ir.py +++ /dev/null @@ -1,411 +0,0 @@ -from __future__ import annotations - -from dataclasses import replace -from pathlib import Path -import unittest - -from app.main import compile_reactflow_network, compile_system_xml_network -from app.simulation.solvers.causal_ir import ( - CausalIROpcode, - compile_causal_numeric_ir, -) -from app.simulation.systems.generic import GenericFluidSystem -from app.system_xml import validate_system_xml_document -from tests.test_amesim_mechanical_xml import zero_force_mass_project -from tests.test_amesim_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 - - -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 - ) - second = compile_causal_numeric_ir( - _system(zero_force_mass_project()).pressure_flow_solver - ) - - self.assertTrue(first.supported) - self.assertTrue(second.supported) - assert first.ir is not None and second.ir is not None - self.assertEqual( - first.ir.program.structural_signature, - second.ir.program.structural_signature, - ) - self.assertEqual( - first.ir.program.structural_signature, - first.ir.program.calculate_structural_signature(), - ) - self.assertEqual(len(first.ir.program.structural_signature), 64) - - changed_binding = replace( - first.ir, - bindings=replace( - first.ir.bindings, - evaluators=tuple( - (lambda evaluate=evaluate: evaluate()) - for evaluate in first.ir.bindings.evaluators - ), - ), - ) - self.assertEqual( - changed_binding.program.structural_signature, - first.ir.program.structural_signature, - ) - - def test_reference_interpreter_matches_existing_object_plan_bitwise(self) -> None: - system = _system(high_pressure_helium_step_project()) - state = system.initial_state_vector() - system.rhs(0.041, state) - solver = system.pressure_flow_solver - compilation = compile_causal_numeric_ir(solver) - self.assertTrue(compilation.supported, compilation.fallback_reason) - assert compilation.ir is not None - - before = tuple(unknown.read() for unknown in solver.unknowns) - self.assertTrue(solver._execute_causal_effort_plan(("p",))) - for unknown in solver._explicit_flow_unknowns_by_variables[ - frozenset(("f", "m_flow")) - ]: - unknown.write(0.0) - expected_flow_stages: list[tuple[float, ...]] = [] - for stage in solver._explicit_flow_plan: - values = solver._evaluate_explicit_flow_stage(stage) - expected_flow_stages.append(values) - for assignment, value in zip(stage.assignments, values): - assignment.unknown.write(value) - expected = tuple(unknown.read() for unknown in solver.unknowns) - for unknown, value in zip(solver.unknowns, before): - unknown.write(value) - - workspace = compilation.ir.create_workspace() - observed: list[tuple[str, int, tuple[int, ...], tuple[float, ...]]] = [] - result = compilation.ir.execute( - workspace, - stage_observer=lambda phase, index, slots, values: observed.append( - (phase, index, slots, values) - ), - ) - actual = tuple(unknown.read() for unknown in solver.unknowns) - - self.assertTrue(result.success, result.fallback_reason) - self.assertEqual(actual, expected) - self.assertEqual( - result.effort_assignment_count, - len(compilation.ir.program.effort_stages[0].operations), - ) - self.assertEqual( - result.flow_assignment_count, - compilation.ir.program.flow_assignment_count, - ) - self.assertEqual(observed[0][0], "effort:p") - self.assertEqual( - sum(item[0] == "flow" for item in observed), - len(compilation.ir.program.flow_stages), - ) - self.assertEqual( - tuple(item[3] for item in observed if item[0] == "flow"), - tuple(expected_flow_stages), - ) - - def test_workspace_is_reused_and_normal_execution_does_not_snapshot(self) -> None: - system = _system(zero_force_mass_project()) - system.rhs(0.0, system.initial_state_vector()) - compilation = compile_causal_numeric_ir(system.pressure_flow_solver) - assert compilation.ir is not None - workspace = compilation.ir.create_workspace() - array_ids = ( - id(workspace.canonical_values), - id(workspace.effort_residuals), - id(workspace.flow_values), - id(workspace.transaction_values), - ) - workspace.transaction_values.fill(float("nan")) - - first = compilation.ir.execute(workspace) - second = compilation.ir.execute(workspace) - - self.assertTrue(first.success) - self.assertTrue(second.success) - self.assertEqual( - array_ids, - ( - id(workspace.canonical_values), - id(workspace.effort_residuals), - id(workspace.flow_values), - id(workspace.transaction_values), - ), - ) - self.assertTrue( - all(value != value for value in workspace.transaction_values) - ) - - def test_transactional_audit_rolls_back_partial_flow_failure(self) -> None: - system = _system(zero_force_mass_project()) - system.rhs(0.0, system.initial_state_vector()) - solver = system.pressure_flow_solver - compilation = compile_causal_numeric_ir(solver) - assert compilation.ir is not None - ir = compilation.ir - before = tuple(unknown.read() for unknown in solver.unknowns) - operation = ir.program.flow_stages[0].operations[0] - evaluators = list(ir.bindings.evaluators) - original = evaluators[operation.evaluator_slot] - - if operation.opcode is CausalIROpcode.FLOW_DIRECT: - evaluators[operation.evaluator_slot] = lambda: float("nan") - else: - equation_index = operation.equation_indices[0] - - def one_nonfinite_component(): - values = list(original()) - values[equation_index] = float("nan") - return tuple(values) - - evaluators[operation.evaluator_slot] = one_nonfinite_component - failing_ir = replace( - ir, - bindings=replace(ir.bindings, evaluators=tuple(evaluators)), - ) - - result = failing_ir.execute( - failing_ir.create_workspace(), - transactional=True, - ) - - self.assertFalse(result.success) - self.assertEqual(result.fallback_reason, "nonFiniteFlowAssignment") - self.assertTrue(result.rolled_back) - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - before, - ) - - def test_unsupported_plan_returns_existing_proof_reason(self) -> None: - solver = _system(chain_project()).pressure_flow_solver - compilation = compile_causal_numeric_ir(solver) - - self.assertFalse(compilation.supported) - self.assertIsNone(compilation.ir) - self.assertEqual( - compilation.fallback_reason, - solver._causal_fast_path_fallback_reason, - ) - - -class TargetCausalNumericIRStructureTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - report = validate_system_xml_document(TARGET_XML.read_bytes()) - assert report.valid and report.document is not None - cls.system = GenericFluidSystem( - compile_system_xml_network(report.document) - ) - cls.compilation = compile_causal_numeric_ir( - cls.system.pressure_flow_solver - ) - - def test_target_has_expected_canonical_and_compatibility_coordinates( - self, - ) -> None: - self.assertTrue( - self.compilation.supported, - self.compilation.fallback_reason, - ) - assert self.compilation.ir is not None - program = self.compilation.ir.program - - self.assertEqual(len(program.compatibility_slots), 776) - self.assertEqual(len(program.canonical_slots), 452) - self.assertEqual(program.effort_group_count, 116) - self.assertEqual(program.flow_assignment_count, 336) - self.assertEqual(program.effort_scatter_count, 440) - self.assertEqual(program.eliminated_effort_replica_count, 324) - self.assertEqual( - tuple(slot.slot for slot in program.canonical_slots), - tuple(range(452)), - ) - - def test_target_batches_efforts_and_preserves_flow_stage_layout(self) -> None: - assert self.compilation.ir is not None - program = self.compilation.ir.program - pressure_stage = next( - stage for stage in program.effort_stages if stage.variable == "p" - ) - - self.assertEqual(len(pressure_stage.operations), 76) - self.assertEqual(len(pressure_stage.evaluations), 40) - 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 - self.system.rhs(0.0, self.system.initial_state_vector()) - before = tuple(unknown.read() for unknown in solver.unknowns) - - self.assertTrue(solver._execute_causal_effort_plan(("p",))) - self.assertIsNone(solver._execute_compiled_causal_flow_plan()) - expected = tuple(unknown.read() for unknown in solver.unknowns) - for unknown, value in zip(solver.unknowns, before): - unknown.write(value) - - result = self.compilation.ir.execute( - self.compilation.ir.create_workspace() - ) - - self.assertTrue(result.success, result.fallback_reason) - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - expected, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_component_interfaces.py b/tests/test_component_interfaces.py index 4115fe1..8fedc32 100644 --- a/tests/test_component_interfaces.py +++ b/tests/test_component_interfaces.py @@ -9,7 +9,6 @@ from app.simulation.components.experimental.storage.tank import Tank from app.simulation.core.base import Component from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition, PortState, PortVariableDefinition -from app.simulation.examples.testmodel.system import TestModelSystem from app.simulation.systems.network import SimulationNetwork from tests.test_system_xml_protocol import physical_connection_project @@ -108,7 +107,7 @@ class ComponentInterfaceTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "variable contracts do not match"): network.connect("cylinder_1", "port_b", "incompatible_1", "port_a") - def test_connection_equations_are_executable_and_endpoint_neutral(self) -> None: + def test_connection_equations_declare_endpoint_neutral_constraints(self) -> None: medium = IdealGasMedium() network = SimulationNetwork("residual-check") cylinder = Cylinder("cylinder_1", medium) @@ -124,13 +123,12 @@ class ComponentInterfaceTests(unittest.TestCase): residuals = { residual.variables[0].rsplit(".", 1)[-1]: residual - for residual in network.connection_equation_residuals() + for residual in network.connection_equation_definitions() } self.assertEqual(set(residuals), {"p", "m_flow"}) self.assertEqual(residuals["p"].relation, "equal") self.assertEqual(residuals["m_flow"].relation, "sumToZero") - self.assertEqual(residuals["p"].value, 0.0) - self.assertEqual(residuals["m_flow"].value, 0.0) + self.assertFalse(hasattr(residuals["p"], "value")) def test_tee_ports_are_physical_and_bidirectional(self) -> None: tee = Tee("tee_1") @@ -146,41 +144,7 @@ class ComponentInterfaceTests(unittest.TestCase): ) ) - def test_existing_testmodel_topology_still_builds(self) -> None: - system = TestModelSystem() - self.assertEqual(len(system.network.components), 8) - self.assertEqual(len(system.network.connections), 8) - self.assertTrue( - all(connection.kind == "physical" for connection in system.network.connections) - ) - self.assertEqual(len(system.initial_state_vector()), 8) - def test_testmodel_snapshot_satisfies_acausal_connection_equations(self) -> None: - system = TestModelSystem() - state = system.consistent_initial_state_vector() - system.snapshot(state) - - residuals = system.network.pressure_flow_equation_residuals() - self.assertEqual(len(residuals), 4 * len(system.network.connections)) - self.assertEqual(len(system.network.pressure_flow_unknowns()), len(residuals)) - for residual in residuals: - tolerance = 1e-6 - self.assertLessEqual( - abs(residual.value), - tolerance, - msg=f"{residual.id} residual is {residual.value}", - ) - - upstream_tee = system.mytee - self.assertAlmostEqual( - upstream_tee.port_in.m_flow - + upstream_tee.port_out1.m_flow - + upstream_tee.port_out2.m_flow, - 0.0, - places=12, - ) - self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out1.p) - self.assertAlmostEqual(upstream_tee.port_in.p, upstream_tee.port_out2.p) if __name__ == "__main__": diff --git a/tests/test_component_metadata.py b/tests/test_component_metadata.py index 96d16f7..3e6515e 100644 --- a/tests/test_component_metadata.py +++ b/tests/test_component_metadata.py @@ -9,7 +9,6 @@ from app.simulation.components.experimental.storage.cylinder import Cylinder from app.simulation.components.experimental.storage.tank import Tank from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortVariableDefinition -from app.simulation.examples.testmodel.dynamic_pipe import Pipe from app.simulation.registry import COMPONENT_MODEL_REGISTRY @@ -21,7 +20,6 @@ class ComponentMetadataTests(unittest.TestCase): return ( Cylinder("cylinder_1", self.medium), Tank("tank_1", self.medium), - Pipe("pipe_dynamic_1", self.medium), ResistivePipe("pipe_1", self.medium), Orifice("orifice_1"), Tee("tee_1"), @@ -86,17 +84,9 @@ class ComponentMetadataTests(unittest.TestCase): def test_result_metadata_exactly_describes_each_exposed_value(self) -> None: for component in self.component_instances(): with self.subTest(component=component.name): - values = component.result_values() metadata = component.result_variable_metadata() keys = [variable.key for variable in metadata] self.assertEqual(len(keys), len(set(keys))) - self.assertEqual( - set(values), - { - variable.key.removeprefix(f"{component.name}.") - for variable in metadata - }, - ) self.assertTrue(all(variable.label for variable in metadata)) self.assertTrue(all(variable.quantity for variable in metadata)) diff --git a/tests/test_contact_solver_causalization.py b/tests/test_contact_solver_causalization.py deleted file mode 100644 index 04c2342..0000000 --- a/tests/test_contact_solver_causalization.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -from math import exp -import unittest - -from app.simulation.components.amesim.mechanical.translational import ( - AmesimF000, - AmesimLstp00a, - AmesimMecmas21, -) -from app.simulation.core.base import AlgebraicComponent -from app.simulation.core.equations import EquationResidual -from app.simulation.core.medium import IdealGasMedium -from app.simulation.core.ports import PortDefinition -from app.simulation.solvers.algebraic import PressureFlowSolver -from app.simulation.systems.network import SimulationNetwork - - -class _PressureCoupledMechanicalLoad(AlgebraicComponent): - PORTS = ( - PortDefinition.mechanical_translational("mechanical"), - PortDefinition.pneumatic("pneumatic"), - ) - - def __init__( - self, - name: str, - *, - initial_force: float, - solved_force: float, - displacement: float, - ) -> None: - super().__init__(name=name) - self.solved_force = float(solved_force) - self.mechanical = self.register_declared_port("mechanical") - self.pneumatic = self.register_declared_port("pneumatic") - self.mechanical.x = float(displacement) - self.pneumatic.p = float(initial_force) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:velocity_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.mechanical.v",), - role="effort", - value=self.mechanical.v, - ), - EquationResidual( - id=f"{self.name}:pressure_force", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=( - f"{self.name}.mechanical.f", - f"{self.name}.pneumatic.p", - ), - role="flow", - value=self.mechanical.f + self.pneumatic.p, - ), - EquationResidual( - id=f"{self.name}:pressure_closure", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.pneumatic.p",), - role="effort", - value=self.pneumatic.p - self.solved_force, - ), - EquationResidual( - id=f"{self.name}:zero_mass_flow", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.pneumatic.m_flow",), - role="flow", - value=self.pneumatic.m_flow, - ), - ) - - -class _PrescribedMechanicalLoad(AlgebraicComponent): - PORTS = (PortDefinition.mechanical_translational("port_1"),) - - def __init__( - self, - name: str, - *, - force: float, - displacement: float, - velocity: float, - ) -> None: - super().__init__(name=name) - self.force = float(force) - self.velocity = float(velocity) - self.port_1 = self.register_declared_port("port_1") - self.port_1.x = float(displacement) - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:velocity_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_1.v",), - role="effort", - value=self.port_1.v - self.velocity, - ), - EquationResidual( - id=f"{self.name}:force", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_1.f",), - role="flow", - value=self.port_1.f - self.force, - ), - ) - - -class ContactSolverCausalizationTests(unittest.TestCase): - def test_nonlinear_binding_tracks_sub_ulp_force_change(self) -> None: - medium = IdealGasMedium() - load = _PressureCoupledMechanicalLoad( - "load", - initial_force=40.0, - solved_force=41.0, - displacement=1.0e9, - ) - contact = AmesimLstp00a( - "contact", - medium, - gap0=0.0, - kcont=1.0e11, - rcont=0.0, - Pdis=1.0e-7, - discContactOption=1.0, - ) - mass = AmesimMecmas21( - "mass", - medium, - mass=1.0, - useFriction=1.0, - stoptype=4.0, - x0=1.0e9, - v0=0.0, - ) - zero = AmesimF000("zero") - - network = SimulationNetwork("sub-ulp-dynamic-contact-binding") - for component in (load, contact, mass, zero): - network.add_component(component) - network.connect("load", "mechanical", "contact", "port_1") - network.connect("contact", "port_2", "mass", "port_1") - network.connect("mass", "port_2", "zero", "port_1") - - self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) - diagnostics = PressureFlowSolver(network).solve() - - self.assertTrue(diagnostics.success, diagnostics.message) - self.assertGreater(diagnostics.evaluations, 0) - self.assertEqual(diagnostics.jacobian_mode, "dense") - self.assertFalse(diagnostics.dense_fallback_used) - self.assertAlmostEqual(load.pneumatic.p, 41.0, delta=1.0e-3) - self.assertAlmostEqual(load.mechanical.f, -41.0, delta=1.0e-3) - self.assertAlmostEqual(contact.contact_force, 41.0, delta=1.0e-3) - self.assertAlmostEqual(contact.penetration, 4.1e-10, delta=1.0e-14) - - def test_negative_contact_binding_uses_nearest_feasible_root(self) -> None: - medium = IdealGasMedium() - expected_force = 10.0 - 20.0 * (1.0 - exp(-1.0)) - load = _PrescribedMechanicalLoad( - "load", - force=-expected_force, - displacement=0.0, - velocity=0.0, - ) - contact = AmesimLstp00a( - "contact", - medium, - gap0=0.0, - kcont=100.0, - rcont=10.0, - Pdis=0.1, - discContactOption=1.0, - ) - mass = AmesimMecmas21( - "mass", - medium, - mass=1.0, - useFriction=1.0, - stoptype=4.0, - x0=-0.08, - v0=2.0, - ) - zero = AmesimF000("zero") - - network = SimulationNetwork("negative-contact-force-binding") - for component in (load, contact, mass, zero): - network.add_component(component) - network.connect("load", "port_1", "contact", "port_1") - network.connect("contact", "port_2", "mass", "port_1") - network.connect("mass", "port_2", "zero", "port_1") - - self.assertTrue(network.pressure_flow_structure_dict()["isSquare"]) - diagnostics = PressureFlowSolver(network).solve() - - self.assertTrue(diagnostics.success, diagnostics.message) - self.assertLess(contact.contact_force, 0.0) - self.assertAlmostEqual(contact.contact_force, expected_force, places=10) - self.assertAlmostEqual(contact.penetration, 0.1, places=10) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_core_solver.py b/tests/test_core_solver.py deleted file mode 100644 index 90dad5c..0000000 --- a/tests/test_core_solver.py +++ /dev/null @@ -1,1577 +0,0 @@ -import math -import sys -import threading -import types -import unittest -from unittest.mock import patch - -from app.simulation.core.errors import RecoverableTrialStateError -from app.simulation.solvers.solver import ( - IntegrationCancelled, - SolveIVPConfig, - SolverActivityTracker, - StateTransition, - integrate_ode, -) - - -def _counting_fixed_step_solver( - step_size: float, - dense_output_times: list[float], -): - import numpy as np - - class FixedStepSolver: - def __init__(self, _fun, t0, y0, t_bound, **_kwargs): - 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.t = min(self.t + step_size, self.t_bound) - self.y = np.asarray([self.t], dtype=float) - if self.t >= self.t_bound: - self.status = "finished" - return None - - def dense_output(self): - dense_output_times.append(self.t) - return lambda time: np.asarray([float(time)], dtype=float) - - return FixedStepSolver - - -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) - - def test_stepwise_jacobian_cancellation_returns_cancelled_solution(self) -> None: - class CancellingJacobian: - def start_segment(self) -> None: - pass - - def observe(self, *_args) -> None: - pass - - def __call__(self, _time, _state): - raise IntegrationCancelled - - result = integrate_ode( - rhs=lambda _time, state: [-float(state[0])], - initial_state=[1.0], - config=SolveIVPConfig(t_stop=1.0, method="BDF"), - cancel_check=lambda: False, - jac=CancellingJacobian(), - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "cancelled") - self.assertEqual(result.t, [0.0]) - - def test_stepwise_explicit_solver_ignores_callable_jacobian(self) -> None: - class UnexpectedJacobian: - call_count = 0 - - def start_segment(self) -> None: - self.call_count += 1 - - def observe(self, *_args) -> None: - self.call_count += 1 - - def __call__(self, _time, _state): - self.call_count += 1 - raise AssertionError("Explicit solver evaluated Jacobian") - - jacobian = UnexpectedJacobian() - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig(t_stop=0.01, method="RK45"), - state_transition_handler=lambda *_args: None, - jac=jacobian, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(jacobian.call_count, 0) - - def test_scipy_solver_receives_step_size_controls(self) -> None: - calls: list[dict[str, object]] = [] - - def fake_solve_ivp(**kwargs): - calls.append(kwargs) - return object() - - scipy_module = types.ModuleType("scipy") - integrate_module = types.ModuleType("scipy.integrate") - integrate_module.solve_ivp = fake_solve_ivp - scipy_module.integrate = integrate_module - - with patch.dict( - sys.modules, - {"scipy": scipy_module, "scipy.integrate": integrate_module}, - ): - integrate_ode( - rhs=lambda _time, state: state, - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - max_step=1.0e-4, - first_step=1.0e-8, - ), - t_eval=[0.0, 1.0], - ) - - self.assertEqual(calls[0]["max_step"], 1.0e-4) - self.assertEqual(calls[0]["first_step"], 1.0e-8) - - def test_direct_implicit_solver_receives_callable_jacobian(self) -> None: - calls: list[dict[str, object]] = [] - observed: list[tuple[float, list[float], list[float]]] = [] - - class RecordingJacobian: - def __init__(self) -> None: - self.segment_count = 0 - - def start_segment(self) -> None: - self.segment_count += 1 - - def observe(self, time, state, derivative) -> None: - observed.append( - ( - float(time), - [float(value) for value in state], - [float(value) for value in derivative], - ) - ) - - def __call__(self, _time, _state): - return [[1.0]] - - def fake_solve_ivp(**kwargs): - calls.append(kwargs) - kwargs["fun"](0.0, [2.0]) - return object() - - scipy_module = types.ModuleType("scipy") - integrate_module = types.ModuleType("scipy.integrate") - integrate_module.solve_ivp = fake_solve_ivp - scipy_module.integrate = integrate_module - jacobian = RecordingJacobian() - - with patch.dict( - sys.modules, - {"scipy": scipy_module, "scipy.integrate": integrate_module}, - ): - integrate_ode( - rhs=lambda _time, state: [2.0 * state[0]], - initial_state=[1.0], - config=SolveIVPConfig(t_start=0.0, t_stop=1.0, method="BDF"), - jac_sparsity=[[True]], - jac=jacobian, - ) - - self.assertIs(calls[0]["jac"], jacobian) - self.assertNotIn("jac_sparsity", calls[0]) - self.assertEqual(jacobian.segment_count, 1) - self.assertEqual(observed, [(0.0, [2.0], [4.0])]) - - def test_scipy_solver_omits_unset_first_step(self) -> None: - calls: list[dict[str, object]] = [] - - def fake_solve_ivp(**kwargs): - calls.append(kwargs) - return object() - - scipy_module = types.ModuleType("scipy") - integrate_module = types.ModuleType("scipy.integrate") - integrate_module.solve_ivp = fake_solve_ivp - scipy_module.integrate = integrate_module - - with patch.dict( - sys.modules, - {"scipy": scipy_module, "scipy.integrate": integrate_module}, - ): - integrate_ode( - rhs=lambda _time, state: state, - initial_state=[1.0], - config=SolveIVPConfig(t_start=0.0, t_stop=1.0), - ) - - self.assertEqual(calls[0]["max_step"], 1.0e-3) - self.assertNotIn("first_step", calls[0]) - - def test_recoverable_trial_retries_default_keeps_direct_scipy_route( - self, - ) -> None: - import scipy.integrate - - expected = object() - with patch.object( - scipy.integrate, - "solve_ivp", - return_value=expected, - ) as direct_solve: - result = integrate_ode( - rhs=lambda _time, state: state, - initial_state=[1.0], - config=SolveIVPConfig(t_start=0.0, t_stop=1.0), - ) - - self.assertIs(result, expected) - direct_solve.assert_called_once() - - def test_recoverable_stepwise_route_matches_direct_scipy_without_failures( - self, - ) -> None: - import numpy as np - - sample_times = [0.0, 0.025, 0.05, 0.075, 0.1] - - def rhs(time, state): - return [-2.0 * float(state[0]) + math.sin(float(time))] - - for method in ("RK45", "BDF"): - with self.subTest(method=method): - config = SolveIVPConfig( - t_start=0.0, - t_stop=0.1, - method=method, - rtol=1.0e-9, - atol=1.0e-12, - max_step=0.01, - ) - direct = integrate_ode( - rhs=rhs, - initial_state=[1.0], - config=config, - t_eval=sample_times, - ) - stepwise = integrate_ode( - rhs=rhs, - initial_state=[1.0], - config=config, - t_eval=sample_times, - recoverable_trial_retries=True, - ) - - self.assertTrue(direct.success, direct.message) - self.assertTrue(stepwise.success, stepwise.message) - np.testing.assert_array_equal(direct.t, stepwise.t) - np.testing.assert_allclose( - direct.y, - stepwise.y, - rtol=1.0e-12, - atol=1.0e-14, - ) - self.assertEqual( - int(direct.nfev), - sum(segment.nfev for segment in stepwise.solver_segments), - ) - self.assertEqual( - int(getattr(direct, "njev", 0)), - sum(segment.njev for segment in stepwise.solver_segments), - ) - self.assertEqual( - int(getattr(direct, "nlu", 0)), - sum(segment.nlu for segment in stepwise.solver_segments), - ) - - def test_stepwise_solver_rebuilds_after_recoverable_trial_failure(self) -> None: - import numpy as np - import scipy.integrate - - attempted_max_steps: list[float] = [] - - class RetryBdf: - 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.h_abs = float(kwargs["max_step"]) - self.status = "running" - attempted_max_steps.append(self.h_abs) - - def step(self): - if self.h_abs > 0.25: - raise RecoverableTrialStateError("trial state outside domain") - self.t = self.t_bound - self.status = "finished" - return None - - def dense_output(self): - state = self.y.copy() - return lambda _time: state.copy() - - with patch.object(scipy.integrate, "BDF", RetryBdf): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - t_eval=[0.0, 1.0], - recoverable_trial_retries=True, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(attempted_max_steps, [1.0, 0.5, 0.25]) - self.assertEqual(result.t, [0.0, 1.0]) - self.assertEqual(result.y, [[1.0, 1.0]]) - - def test_stepwise_solver_retries_recoverable_constructor_failure(self) -> None: - import numpy as np - import scipy.integrate - - attempts: list[dict[str, object]] = [] - - class ConstructorRetryBdf: - def __init__(self, _fun, t0, y0, t_bound, **kwargs): - attempts.append(dict(kwargs)) - if float(kwargs["max_step"]) > 0.25: - raise RecoverableTrialStateError("constructor trial failed") - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.t_bound = float(t_bound) - self.status = "running" - - def step(self): - self.t = self.t_bound - self.status = "finished" - return None - - def dense_output(self): - state = self.y.copy() - return lambda _time: state.copy() - - with patch.object(scipy.integrate, "BDF", ConstructorRetryBdf): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - cancel_check=lambda: False, - ) - - self.assertTrue(result.success, result.message) - self.assertNotIn("first_step", attempts[0]) - self.assertEqual( - [attempt["max_step"] for attempt in attempts], - [1.0, 0.5, 0.25], - ) - self.assertEqual( - [attempt.get("first_step") for attempt in attempts], - [None, 0.5, 0.25], - ) - segment = result.solver_segments[0] - self.assertEqual(segment.recoverable_retry_count, 2) - self.assertEqual( - [retry.phase for retry in segment.recoverable_retries], - ["constructor", "constructor"], - ) - self.assertEqual( - segment.as_dict()["recoverableRetries"][0], - { - "phase": "constructor", - "attemptedStep": 1.0, - "reason": "constructor trial failed", - "nextMaxStep": 0.5, - "nextFirstStep": 0.5, - }, - ) - - def test_stepwise_retry_uses_solver_actual_step_not_segment_maximum( - self, - ) -> None: - import numpy as np - import scipy.integrate - - attempts: list[dict[str, object]] = [] - - class ActualStepRetryBdf: - def __init__(self, _fun, t0, y0, t_bound, **kwargs): - self.attempt_index = len(attempts) - attempts.append(dict(kwargs)) - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.t_bound = float(t_bound) - self.h_abs = min(0.04, float(kwargs["max_step"])) - self.step_size = min(0.03, float(kwargs["max_step"])) - self.status = "running" - - def step(self): - if self.attempt_index == 0: - raise RecoverableTrialStateError("small actual trial failed") - self.t = self.t_bound - self.status = "finished" - return None - - def dense_output(self): - state = self.y.copy() - return lambda _time: state.copy() - - with patch.object(scipy.integrate, "BDF", ActualStepRetryBdf): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - cancel_check=lambda: False, - ) - - self.assertTrue(result.success, result.message) - self.assertNotIn("first_step", attempts[0]) - self.assertEqual(attempts[1]["max_step"], 0.02) - self.assertEqual(attempts[1]["first_step"], 0.02) - retry = result.solver_segments[0].recoverable_retries[0] - self.assertEqual(retry.phase, "step") - self.assertEqual(retry.attempted_step, 0.04) - self.assertEqual(retry.next_max_step, 0.02) - - def test_stepwise_retry_falls_back_to_previous_step_size_without_h_abs( - self, - ) -> None: - import numpy as np - import scipy.integrate - - for invalid_h_abs in (None, math.nan): - with self.subTest(h_abs=invalid_h_abs): - attempts: list[dict[str, object]] = [] - - class StepSizeFallbackBdf: - def __init__(self, _fun, t0, y0, t_bound, **kwargs): - self.attempt_index = len(attempts) - attempts.append(dict(kwargs)) - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.t_bound = float(t_bound) - self.h_abs = invalid_h_abs - self.step_size = 0.06 - self.status = "running" - - def step(self): - if self.attempt_index == 0: - raise RecoverableTrialStateError( - "trial without valid h_abs" - ) - self.t = self.t_bound - self.status = "finished" - return None - - def dense_output(self): - state = self.y.copy() - return lambda _time: state.copy() - - with patch.object( - scipy.integrate, - "BDF", - StepSizeFallbackBdf, - ): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - cancel_check=lambda: False, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(attempts[1]["max_step"], 0.03) - self.assertEqual(attempts[1]["first_step"], 0.03) - retry = result.solver_segments[0].recoverable_retries[0] - self.assertEqual(retry.attempted_step, 0.06) - self.assertEqual(retry.next_max_step, 0.03) - - def test_accepted_step_clears_recoverable_retry_state(self) -> None: - import numpy as np - import scipy.integrate - - attempts: list[dict[str, object]] = [] - caps_after_accepted_retry: list[float] = [] - - class FailureAfterAcceptedRetryBdf: - def __init__(self, _fun, t0, y0, t_bound, **kwargs): - self.attempt_index = len(attempts) - attempts.append(dict(kwargs)) - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.t_bound = float(t_bound) - self.h_abs = min(0.2, float(kwargs["max_step"])) - self.status = "running" - self.step_count = 0 - - def step(self): - self.step_count += 1 - if self.attempt_index == 0: - raise RecoverableTrialStateError("retry once") - if self.step_count == 1: - self.t = 0.25 - return None - caps_after_accepted_retry.append(float(self.max_step)) - self.status = "failed" - return "ordinary failure after accepted step" - - with patch.object( - scipy.integrate, - "BDF", - FailureAfterAcceptedRetryBdf, - ): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - cancel_check=lambda: False, - ) - - self.assertFalse(result.success) - self.assertEqual(result.message, "ordinary failure after accepted step") - self.assertEqual(len(attempts), 2) - self.assertEqual(caps_after_accepted_retry, [1.0]) - self.assertEqual(result.solver_segments[0].accepted_step_count, 1) - self.assertEqual(result.solver_segments[0].recoverable_retry_count, 1) - - def test_transition_restart_does_not_inherit_retry_first_step(self) -> None: - import numpy as np - import scipy.integrate - - attempts: list[dict[str, object]] = [] - transition_pending = True - - class TransitionAfterRetryBdf: - def __init__(self, _fun, t0, y0, t_bound, **kwargs): - self.attempt_index = len(attempts) - attempts.append(dict(kwargs)) - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.t_bound = float(t_bound) - self.h_abs = min(0.2, float(kwargs["max_step"])) - self.status = "running" - - def step(self): - if self.attempt_index == 0: - raise RecoverableTrialStateError("retry before transition") - self.t = self.t_bound - self.y = np.asarray([self.t], dtype=float) - self.status = "finished" - return None - - def dense_output(self): - return lambda time: np.asarray([float(time)], dtype=float) - - def transition_handler( - previous_time, - _previous_state, - current_time, - _current_state, - _dense_state, - ): - nonlocal transition_pending - if transition_pending and previous_time <= 0.25 <= current_time: - transition_pending = False - return StateTransition(time=0.25, state=[0.25]) - return None - - with patch.object(scipy.integrate, "BDF", TransitionAfterRetryBdf): - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - state_transition_handler=transition_handler, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(len(attempts), 3) - self.assertNotIn("first_step", attempts[0]) - self.assertEqual(attempts[1]["first_step"], 0.1) - self.assertEqual(attempts[1]["max_step"], 0.1) - self.assertNotIn("first_step", attempts[2]) - self.assertEqual(attempts[2]["max_step"], 1.0) - - def test_recoverable_retry_stops_at_minimum_step(self) -> None: - import numpy as np - import scipy.integrate - - minimum_step = 64.0 * math.ulp(1.0) - attempts = 0 - - class MinimumStepBdf: - def __init__(self, _fun, t0, y0, _t_bound, **_kwargs): - nonlocal attempts - attempts += 1 - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.h_abs = 2.0 * minimum_step - self.status = "running" - - def step(self): - raise RecoverableTrialStateError("minimum step reached") - - with patch.object(scipy.integrate, "BDF", MinimumStepBdf): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0), - cancel_check=lambda: False, - ) - - self.assertFalse(result.success) - self.assertEqual(result.message, "minimum step reached") - self.assertEqual(attempts, 1) - retry = result.solver_segments[0].recoverable_retries[0] - self.assertEqual(retry.attempted_step, 2.0 * minimum_step) - self.assertIsNone(retry.next_max_step) - - def test_recoverable_retry_has_sixteen_retry_limit(self) -> None: - import scipy.integrate - - attempts: list[dict[str, object]] = [] - - class AlwaysFailingConstructorBdf: - def __init__(self, _fun, _t0, _y0, _t_bound, **kwargs): - attempts.append(dict(kwargs)) - raise RecoverableTrialStateError("persistent trial failure") - - with patch.object( - scipy.integrate, - "BDF", - AlwaysFailingConstructorBdf, - ): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0), - cancel_check=lambda: False, - ) - - self.assertFalse(result.success) - self.assertEqual(len(attempts), 17) - diagnostics = result.solver_segments[0].recoverable_retries - self.assertEqual(len(diagnostics), 17) - self.assertEqual( - sum(retry.next_max_step is not None for retry in diagnostics), - 16, - ) - self.assertIsNone(diagnostics[-1].next_max_step) - - def test_recoverable_retry_does_not_swallow_cancellation(self) -> None: - import numpy as np - import scipy.integrate - - attempts = 0 - - class CancellingBdf: - def __init__(self, _fun, t0, y0, _t_bound, **_kwargs): - nonlocal attempts - attempts += 1 - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.status = "running" - - def step(self): - raise IntegrationCancelled - - with patch.object(scipy.integrate, "BDF", CancellingBdf): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig(t_stop=1.0, method="BDF", max_step=1.0), - cancel_check=lambda: False, - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "cancelled") - self.assertEqual(attempts, 1) - self.assertEqual(result.solver_segments[0].recoverable_retry_count, 0) - - def test_stepwise_solver_does_not_retry_ordinary_model_errors(self) -> None: - import numpy as np - import scipy.integrate - - attempts = 0 - - class FailingBdf: - def __init__(self, fun, t0, y0, t_bound, **kwargs): - nonlocal attempts - attempts += 1 - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.status = "running" - - def step(self): - raise ValueError("structural model error") - - with patch.object(scipy.integrate, "BDF", FailingBdf): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig(t_start=0.0, t_stop=1.0, method="BDF"), - cancel_check=lambda: False, - ) - - self.assertFalse(result.success) - self.assertEqual(result.message, "structural model error") - self.assertEqual(attempts, 1) - - def test_scipy_stepwise_solver_can_cancel_before_start(self) -> None: - result = integrate_ode( - rhs=lambda _time, state: state, - initial_state=[1.0], - config=SolveIVPConfig(t_start=0.0, t_stop=1.0), - cancel_check=lambda: True, - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "cancelled") - self.assertEqual(result.t, [0.0]) - - def test_stepwise_solvers_build_dense_output_only_when_crossing_a_sample( - self, - ) -> None: - import scipy.integrate - - for method in ("BDF", "Radau", "RK45"): - with self.subTest(method=method): - dense_output_times: list[float] = [] - fixed_step_solver = _counting_fixed_step_solver( - 0.2, - dense_output_times, - ) - - with patch.object(scipy.integrate, method, fixed_step_solver): - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method=method, - max_step=1.0, - ), - t_eval=[0.0, 0.75, 1.0], - cancel_check=lambda: False, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(result.t, [0.0, 0.75, 1.0]) - self.assertEqual(result.y, [[0.0, 0.75, 1.0]]) - self.assertEqual(dense_output_times, [0.8, 1.0]) - self.assertEqual( - result.solver_segments[0].accepted_step_count, - 5, - ) - - def test_stepwise_solvers_keep_dense_output_for_state_event_detection( - self, - ) -> None: - import scipy.integrate - - for method in ("BDF", "Radau", "RK45"): - with self.subTest(method=method): - dense_output_times: list[float] = [] - inspected_steps = 0 - fixed_step_solver = _counting_fixed_step_solver( - 0.25, - dense_output_times, - ) - - def inspect_state_event( - previous_time, - _previous_state, - current_time, - _current_state, - dense_state, - ): - nonlocal inspected_steps - inspected_steps += 1 - midpoint = 0.5 * (previous_time + current_time) - self.assertAlmostEqual(dense_state(midpoint)[0], midpoint) - return None - - with patch.object(scipy.integrate, method, fixed_step_solver): - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method=method, - max_step=1.0, - ), - t_eval=[0.0, 1.0], - state_transition_handler=inspect_state_event, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(len(dense_output_times), 4) - self.assertEqual(inspected_steps, 4) - - def test_stepwise_dense_output_preserves_adjacent_float_samples(self) -> None: - import scipy.integrate - - dense_output_times: list[float] = [] - adjacent_time = math.nextafter(0.5, math.inf) - half_interval_solver = _counting_fixed_step_solver( - 0.5, - dense_output_times, - ) - - with patch.object(scipy.integrate, "RK45", half_interval_solver): - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="RK45", - max_step=1.0, - ), - t_eval=[0.0, 0.5, adjacent_time, 1.0], - cancel_check=lambda: False, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(result.t, [0.0, 0.5, adjacent_time, 1.0]) - self.assertEqual(result.y[0], result.t) - self.assertEqual(dense_output_times, [0.5, 1.0]) - - def test_dense_output_pruning_preserves_cancelled_partial_samples(self) -> None: - import scipy.integrate - - cancellation_requested = False - dense_output_times: list[float] = [] - fixed_step_solver = _counting_fixed_step_solver( - 0.2, - dense_output_times, - ) - - def request_cancel(time: float) -> None: - nonlocal cancellation_requested - cancellation_requested = time >= 0.4 - - with patch.object(scipy.integrate, "BDF", fixed_step_solver): - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - t_eval=[0.0, 0.3, 0.8, 1.0], - cancel_check=lambda: cancellation_requested, - accepted_step_callback=request_cancel, - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "cancelled") - self.assertEqual(result.t, [0.0, 0.3, 0.4]) - self.assertEqual(result.y, [[0.0, 0.3, 0.4]]) - self.assertEqual(dense_output_times, [0.4]) - - def test_segmented_bdf_uses_left_limit_and_restarts_at_event(self) -> None: - import scipy.integrate - - event_time = 0.5 - actual_bdf = scipy.integrate.BDF - starts: list[float] = [] - bounds: list[float] = [] - call_times: list[list[float]] = [] - - class RecordingBDF(actual_bdf): - def __init__(self, fun, t0, y0, t_bound, **kwargs): - starts.append(float(t0)) - bounds.append(float(t_bound)) - segment_calls: list[float] = [] - call_times.append(segment_calls) - - def recording_fun(time, state): - segment_calls.append(float(time)) - return fun(time, state) - - super().__init__(recording_fun, t0, y0, t_bound, **kwargs) - - with patch.object(scipy.integrate, "BDF", RecordingBDF): - result = integrate_ode( - rhs=lambda time, _state: [1.0 if time < event_time else 2.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=0.1, - first_step=0.8, - ), - t_eval=[0.0, event_time, event_time, 1.0], - breakpoints=[event_time], - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(starts, [0.0, event_time]) - self.assertEqual(bounds[0], math.nextafter(event_time, -math.inf)) - self.assertEqual(bounds[1], 1.0) - self.assertTrue(call_times[0]) - self.assertTrue(all(time < event_time for time in call_times[0])) - self.assertTrue(any(time >= event_time for time in call_times[1])) - self.assertEqual(result.t, [0.0, event_time, 1.0]) - self.assertAlmostEqual(result.y[0][-1], 1.5, places=5) - - def test_segmented_implicit_solvers_merge_samples_and_report_progress(self) -> None: - event_time = 0.4 - - for method in ("BDF", "Radau"): - with self.subTest(method=method): - callback_times: list[float] = [] - result = integrate_ode( - rhs=lambda time, _state: [1.0 if time < event_time else 3.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method=method, - max_step=0.05, - first_step=0.9, - ), - t_eval=[0.0, event_time, event_time, 0.7, 1.0], - accepted_step_callback=callback_times.append, - breakpoints=[event_time, event_time], - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(result.t, [0.0, event_time, 0.7, 1.0]) - self.assertAlmostEqual(result.y[0][-1], 2.2, places=5) - self.assertEqual(callback_times.count(event_time), 1) - self.assertTrue( - all( - earlier < later - for earlier, later in zip( - callback_times, - callback_times[1:], - ) - ) - ) - - def test_stepwise_implicit_solver_restarts_callable_jacobian(self) -> None: - import numpy as np - import scipy.integrate - - for method in ("BDF", "Radau"): - with self.subTest(method=method): - received: list[dict[str, object]] = [] - - class RecordingJacobian: - def __init__(self) -> None: - self.segment_count = 0 - self.observation_count = 0 - - def start_segment(self) -> None: - self.segment_count += 1 - - def observe(self, _time, _state, _derivative) -> None: - self.observation_count += 1 - - def __call__(self, _time, _state): - return [[0.0]] - - class RecordingSolver: - def __init__(self, fun, t0, y0, t_bound, **kwargs): - received.append(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(self.t, self.y) - self.t = self.t_bound - self.status = "finished" - return None - - def dense_output(self): - state = self.y.copy() - return lambda _time: state.copy() - - jacobian = RecordingJacobian() - with patch.object(scipy.integrate, method, RecordingSolver): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method=method, - max_step=1.0, - ), - t_eval=[0.0, 0.4, 1.0], - breakpoints=[0.4], - jac_sparsity=[[True]], - jac=jacobian, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(jacobian.segment_count, 2) - self.assertEqual(jacobian.observation_count, 2) - self.assertEqual(len(received), 2) - self.assertTrue( - all(options.get("jac") is jacobian for options in received) - ) - self.assertTrue( - all("jac_sparsity" not in options for options in received) - ) - - def test_segmented_solver_reports_implicit_work_by_event_segment(self) -> None: - import numpy as np - import scipy.integrate - - class CountingBDF: - def __init__(self, _fun, t0, y0, t_bound, **_kwargs): - self.t = float(t0) - self.y = np.asarray(y0, dtype=float) - self.t_bound = float(t_bound) - self.status = "running" - self.nfev = 2 - self.njev = 1 - self.nlu = 0 - - def step(self): - self.t = self.t_bound - self.nfev += 3 - self.nlu += 2 - self.status = "finished" - return None - - def dense_output(self): - state = self.y.copy() - return lambda _time: state.copy() - - with patch.object(scipy.integrate, "BDF", CountingBDF): - result = integrate_ode( - rhs=lambda _time, _state: [0.0], - initial_state=[1.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=1.0, - ), - t_eval=[0.0, 0.4, 1.0], - breakpoints=[0.4], - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(len(result.solver_segments), 2) - self.assertEqual( - [segment.as_dict() for segment in result.solver_segments], - [ - { - "startTime": 0.0, - "requestedStopTime": 0.4, - "simulatedUntil": 0.4, - "nfev": 5, - "njev": 1, - "nlu": 2, - "acceptedStepCount": 1, - "solverStartCount": 1, - "stateTransitionCount": 0, - "recoverableRetryCount": 0, - }, - { - "startTime": 0.4, - "requestedStopTime": 1.0, - "simulatedUntil": 1.0, - "nfev": 5, - "njev": 1, - "nlu": 2, - "acceptedStepCount": 1, - "solverStartCount": 1, - "stateTransitionCount": 0, - "recoverableRetryCount": 0, - }, - ], - ) - - def test_segmented_solver_can_cancel_after_crossing_a_breakpoint(self) -> None: - callback_times: list[float] = [] - cancellation_requested = False - - def record_progress(time: float) -> None: - nonlocal cancellation_requested - callback_times.append(time) - cancellation_requested = time >= 0.55 - - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=0.05, - ), - t_eval=[0.0, 0.2, 0.4, 0.6, 0.8, 1.0], - cancel_check=lambda: cancellation_requested, - accepted_step_callback=record_progress, - breakpoints=[0.4, 0.8], - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "cancelled") - self.assertIn(0.4, callback_times) - self.assertGreater(callback_times[-1], 0.4) - self.assertTrue( - all( - earlier < later - for earlier, later in zip(callback_times, callback_times[1:]) - ) - ) - self.assertEqual(result.t, sorted(set(result.t))) - - def test_state_transition_resets_at_root_and_discards_step_overshoot(self) -> None: - event_time = 0.35 - event_enabled = True - - def transition_handler( - previous_time, - previous_state, - current_time, - current_state, - dense_state, - ): - nonlocal event_enabled - if ( - not event_enabled - or previous_state[0] >= event_time - or current_state[0] < event_time - ): - return None - lower = previous_time - upper = current_time - for _iteration in range(60): - middle = 0.5 * (lower + upper) - if dense_state(middle)[0] >= event_time: - upper = middle - else: - lower = middle - event_enabled = False - return StateTransition(time=upper, state=[0.0]) - - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=0.5, - ), - t_eval=[0.0, event_time, 0.4, 1.0], - state_transition_handler=transition_handler, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(result.t, [0.0, event_time, 0.4, 1.0]) - self.assertAlmostEqual(result.y[0][1], 0.0, places=12) - self.assertAlmostEqual(result.y[0][2], 0.05, places=8) - self.assertAlmostEqual(result.y[0][-1], 0.65, places=8) - transition_segments = [ - segment - for segment in result.solver_segments - if segment.state_transition_count - ] - self.assertEqual(len(transition_segments), 1) - self.assertEqual( - transition_segments[0].state_transition_times, - (event_time,), - ) - self.assertEqual( - transition_segments[0].as_dict()["stateTransitionTimes"], - [event_time], - ) - - def test_state_transitions_chain_at_same_time_until_state_repeats(self) -> None: - event_time = 0.25 - stage = 0 - returned_reset_states: list[float] = [] - - def transition_handler( - previous_time, - _previous_state, - current_time, - _current_state, - _dense_state, - ): - nonlocal stage - if stage == 0 and previous_time <= event_time <= current_time: - stage = 1 - returned_reset_states.append(10.0) - return StateTransition(time=event_time, state=[10.0]) - if stage == 1 and previous_time == event_time: - stage = 2 - returned_reset_states.append(20.0) - return StateTransition(time=event_time, state=[20.0]) - if stage == 2 and previous_time == event_time: - returned_reset_states.append(20.0) - return StateTransition(time=event_time, state=[20.0]) - return None - - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=0.4, - ), - t_eval=[0.0, event_time, 1.0], - state_transition_handler=transition_handler, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(returned_reset_states, [10.0, 20.0, 20.0]) - self.assertEqual(result.t, [0.0, event_time, 1.0]) - self.assertEqual(result.y[0][1], 20.0) - self.assertAlmostEqual(result.y[0][-1], 20.75, places=8) - - def test_state_transition_chain_has_a_finite_guard(self) -> None: - event_time = 0.25 - reset_count = 0 - - def transition_handler( - previous_time, - _previous_state, - current_time, - _current_state, - _dense_state, - ): - nonlocal reset_count - if previous_time <= event_time <= current_time: - reset_count += 1 - return StateTransition( - time=event_time, - state=[float(reset_count)], - ) - return None - - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=0.4, - ), - state_transition_handler=transition_handler, - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "failed") - self.assertIn("64 chained resets", result.message) - - def test_stepwise_solver_preserves_adjacent_float_samples(self) -> None: - adjacent_time = math.nextafter(0.5, math.inf) - - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="RK45", - max_step=0.4, - ), - t_eval=[0.0, 0.5, adjacent_time, 1.0], - state_transition_handler=lambda *_args: None, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(result.t, [0.0, 0.5, adjacent_time, 1.0]) - - def test_state_transition_at_breakpoint_uses_exact_breakpoint_sample(self) -> None: - event_time = 0.5 - integration_left_limit = math.nextafter(event_time, -math.inf) - event_enabled = True - - def transition_handler( - previous_time, - _previous_state, - current_time, - _current_state, - _dense_state, - ): - nonlocal event_enabled - if ( - event_enabled - and previous_time <= integration_left_limit <= current_time - ): - event_enabled = False - return StateTransition(time=event_time, state=[7.0]) - return None - - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=0.2, - ), - t_eval=[0.0, event_time, 1.0], - breakpoints=[event_time], - state_transition_handler=transition_handler, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(result.t, [0.0, event_time, 1.0]) - self.assertEqual(result.y[0][1], 7.0) - self.assertAlmostEqual(result.y[0][-1], 7.5, places=8) - - def test_cancellation_after_state_transition_reports_partial_progress(self) -> None: - event_time = 0.25 - cancellation_requested = False - event_enabled = True - - def transition_handler( - previous_time, - _previous_state, - current_time, - _current_state, - _dense_state, - ): - nonlocal cancellation_requested, event_enabled - if event_enabled and previous_time <= event_time <= current_time: - event_enabled = False - cancellation_requested = True - return StateTransition(time=event_time, state=[0.0]) - return None - - result = integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0, - method="BDF", - max_step=0.4, - ), - cancel_check=lambda: cancellation_requested, - state_transition_handler=transition_handler, - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "cancelled") - self.assertEqual( - result.message, - "Simulation was stopped before reaching the requested end time.", - ) - self.assertEqual(result.t[-1], event_time) - - def test_state_transition_handler_rejects_reverse_integration(self) -> None: - with self.assertRaisesRegex( - ValueError, - "does not support reverse integration", - ): - integrate_ode( - rhs=lambda _time, _state: [1.0], - initial_state=[0.0], - config=SolveIVPConfig(t_start=1.0, t_stop=0.0), - state_transition_handler=lambda *_args: None, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_dependency_constraints.py b/tests/test_dependency_constraints.py index dadda67..c2151cd 100644 --- a/tests/test_dependency_constraints.py +++ b/tests/test_dependency_constraints.py @@ -25,9 +25,7 @@ VERIFY_ENVIRONMENT_VARIABLE = "SYSTEM_SIMULATION_VERIFY_LOCKED_ENV" REFERENCE_DIRECT_VERSIONS = { "fastapi": "0.141.1", "lxml": "6.1.1", - "numpy": "2.5.2", "pydantic": "2.13.4", - "scipy": "1.18.0", "uvicorn": "0.52.3", } REFERENCE_LOCKED_VERSIONS = { @@ -40,12 +38,10 @@ REFERENCE_LOCKED_VERSIONS = { "httptools": "0.8.0", "idna": "3.18", "lxml": "6.1.1", - "numpy": "2.5.2", "pydantic": "2.13.4", "pydantic-core": "2.46.4", "python-dotenv": "1.2.3", "pyyaml": "6.0.3", - "scipy": "1.18.0", "starlette": "1.6.0", "typing-extensions": "4.16.0", "typing-inspection": "0.4.4", diff --git a/tests/test_generic_jacobian_sparsity.py b/tests/test_generic_jacobian_sparsity.py deleted file mode 100644 index c48cf3f..0000000 --- a/tests/test_generic_jacobian_sparsity.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -import os -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -import numpy as np -from scipy.integrate._ivp.common import num_jac - -from app.main import ReactFlowProjectPayload, compile_reactflow_network -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.base import DynamicComponent -from app.simulation.core.ports import PortDefinition -from app.simulation.core.medium import IdealGasMedium -from app.simulation.solvers.mechanical import MechanicalConstraintGroup -from app.simulation.systems.generic import ( - GenericFluidSystem, - _requested_ode_jacobian_mode, -) -from app.simulation.systems.network import Endpoint -from tests.test_amesim_pnrp17_xml import pnrp17_coupled_project -from tests.test_generic_system_xml_simulation import component_node, physical_edge -from tests.test_system_xml_protocol import physical_port - - -class OdeJacobianModeTests(unittest.TestCase): - def test_scipy_is_the_safe_default(self) -> None: - with patch.dict(os.environ, {}, clear=True): - self.assertEqual(_requested_ode_jacobian_mode(), "scipy") - - def test_optimized_and_hybrid_require_explicit_selection(self) -> None: - for value, expected in (("optimized", "optimized"), ("hybrid", "hybrid")): - with self.subTest(value=value), patch.dict( - os.environ, - {"SIMULATION_ODE_JACOBIAN_MODE": value}, - ): - self.assertEqual(_requested_ode_jacobian_mode(), expected) - - def test_invalid_mode_is_rejected(self) -> None: - with patch.dict( - os.environ, - {"SIMULATION_ODE_JACOBIAN_MODE": "unknown"}, - ), self.assertRaisesRegex(ValueError, "must be 'optimized'"): - _requested_ode_jacobian_mode() - - -class _DynamicVolumeSource(DynamicComponent): - PORTS = (PortDefinition.pneumatic("port"),) - state_size = 1 - - def __init__(self, name: str) -> None: - super().__init__(name) - self.state = 0.25 - self.port = self.register_declared_port("port") - - def get_state_vector(self) -> list[float]: - return [self.state] - - def set_state_vector(self, values: list[float]) -> None: - self.state = float(values[0]) - - def refresh_thermodynamic_ports(self): - return None - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - return [0.0] - - def pneumatic_volume_outputs(self) -> Mapping[str, tuple[float, float]]: - return {"port": (self.state, 0.0)} - - -class _TrustedDynamicVolumeSource(_DynamicVolumeSource): - pass - - -_TrustedDynamicVolumeSource.__module__ = "app.simulation.components.synthetic" - - -class _UntrustedDynamicVolumeSource(_DynamicVolumeSource): - pass - - -def cross_domain_storage_project() -> ReactFlowProjectPayload: - """PNL storage -> variable chamber -> pneumatic piston -> two masses.""" - - base = pnrp17_coupled_project() - nodes = [node.model_dump() for node in base.nodes] - nodes.append( - component_node( - "line_storage", - "amesim_pnl0001", - [ - physical_port("port_1", "bidirectional", "left"), - physical_port("port_2", "bidirectional", "right"), - ], - { - "diam": 0.01, - "le": 1.0, - "rr": 1.0e-5, - "k": 1.35, - "kth": 0.0, - "extemp": 300.0, - "gi": 0.0, - "mode": 2.0, - "p0": 200000.0, - "T0": 300.0, - }, - ) - ) - edges = [ - edge.model_dump() - for edge in base.edges - if edge.id != "edge-boundary-1" - ] - edges.extend( - ( - physical_edge( - "edge-chamber-line", - "chamber_1", - "port_1", - "line_storage", - "port_1", - ), - physical_edge( - "edge-line-boundary", - "line_storage", - "port_2", - "boundary_1", - "port_1", - ), - ) - ) - return ReactFlowProjectPayload( - projectSchemaVersion=base.projectSchemaVersion, - name="cross-domain-jacobian-sparsity", - nodes=nodes, - edges=edges, - simulation=base.simulation.model_dump(), - ) - - -def state_slices(system: GenericFluidSystem) -> dict[str, slice]: - result: dict[str, slice] = {} - cursor = 0 - for entry in system.mechanical_state_reducer.state_entries: - if isinstance(entry, MechanicalConstraintGroup): - entry_size = 2 - names = tuple(component.name for component in entry.components) - else: - entry_size = entry.state_size - names = (entry.name,) - state_slice = slice(cursor, cursor + entry_size) - for name in names: - result[name] = state_slice - cursor += entry_size - return result - - -class GenericJacobianSparsityTests(unittest.TestCase): - def setUp(self) -> None: - self.system = GenericFluidSystem( - compile_reactflow_network(cross_domain_storage_project()) - ) - - def test_external_volume_connects_mechanical_and_nearby_storage_states(self) -> None: - slices = state_slices(self.system) - pattern = self.system.jacobian_sparsity().toarray().astype(bool) - line_states = range( - slices["line_storage"].start, - slices["line_storage"].stop, - ) - mechanical_states = [ - state_index - for name in ("piston_mass", "cylinder_mass") - for state_index in range(slices[name].start, slices[name].stop) - ] - - self.assertTrue( - pattern[np.ix_(tuple(line_states), tuple(mechanical_states))].all() - ) - self.assertTrue( - pattern[np.ix_(tuple(mechanical_states), tuple(line_states))].all() - ) - - def _apply_dynamic_volume_dependency_probe( - self, - source: _DynamicVolumeSource, - ) -> list[set[int]]: - medium = IdealGasMedium() - receiver = Cylinder("receiver", medium, V=0.1, p0=200_000.0) - remote = Tank("remote", medium, V=0.1, p0=100_000.0) - system = GenericFluidSystem.__new__(GenericFluidSystem) - system.pneumatic_volume_resolver = SimpleNamespace( - _output_components=(source,), - _connected_endpoint={ - Endpoint(source.name, "port"): SimpleNamespace( - connected_endpoint=Endpoint("receiver", "port_b") - ) - }, - ) - dependencies = [ - {0, 1}, - {0, 1}, - {1, 2}, - ] - system._add_pneumatic_volume_state_dependencies( - dependencies, - (source, receiver, remote), - {"source": 0, "receiver": 1, "remote": 2}, - ) - return dependencies - - def test_dynamic_volume_source_ode_state_drives_remote_pneumatic_state( - self, - ) -> None: - dependencies = self._apply_dynamic_volume_dependency_probe( - _TrustedDynamicVolumeSource("source") - ) - - self.assertIn(0, dependencies[2]) - self.assertIn(2, dependencies[0]) - - def test_untrusted_volume_source_disables_ode_jacobian_sparsity(self) -> None: - dependencies = self._apply_dynamic_volume_dependency_probe( - _UntrustedDynamicVolumeSource("source") - ) - - self.assertEqual(dependencies, [{0, 1, 2}] * 3) - - def test_dense_numerical_jacobian_has_no_significant_entry_outside_pattern( - self, - ) -> None: - state = np.asarray(self.system.consistent_initial_state_vector(0.0)) - slices = state_slices(self.system) - # Move one piston face away from the zero-volume reference so the - # chamber/line flow has a measurable local volume derivative. This is - # an operating-point probe only; the state remains well inside the - # chamber's positive total-volume domain. - state[slices["piston_mass"].stop - 1] = 1.0e-3 - - def evaluate_one(values: np.ndarray) -> np.ndarray: - return np.asarray( - self.system.rhs(0.0, [float(value) for value in values]) - ) - - def evaluate(_time: float, values: np.ndarray) -> np.ndarray: - if values.ndim == 1: - return evaluate_one(values) - return np.column_stack( - [evaluate_one(values[:, index]) for index in range(values.shape[1])] - ) - - derivative = evaluate(0.0, state) - absolute_tolerance = np.asarray( - self.system.mechanical_state_reducer.absolute_tolerances(1.0e-8) - ) - dense_jacobian, _factor = num_jac( - evaluate, - 0.0, - state, - derivative, - absolute_tolerance / 1.0e-6, - None, - None, - ) - pattern = self.system.jacobian_sparsity().toarray().astype(bool) - missed = np.abs(np.asarray(dense_jacobian)) * ~pattern - column_scale = np.maximum( - 1.0, - np.max(np.abs(np.asarray(dense_jacobian)), axis=0), - ) - - self.assertFalse( - np.any(missed > 1.0e-6 * column_scale[np.newaxis, :]), - f"maximum omitted derivative was {float(np.max(missed))}", - ) - line_rows = range( - slices["line_storage"].start, - slices["line_storage"].stop, - ) - piston_columns = range( - slices["piston_mass"].start, - slices["piston_mass"].stop, - ) - self.assertGreater( - float( - np.max( - np.abs( - np.asarray(dense_jacobian)[ - np.ix_(tuple(line_rows), tuple(piston_columns)) - ] - ) - ) - ), - 1.0, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_generic_system_xml_simulation.py b/tests/test_generic_system_xml_simulation.py index 59459fe..5905678 100644 --- a/tests/test_generic_system_xml_simulation.py +++ b/tests/test_generic_system_xml_simulation.py @@ -26,14 +26,11 @@ 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 ( +from app.simulation.config import ( SolveIVPConfig, SolverActivityTracker, ) -from app.simulation.systems.generic import ( - GenericFluidSystem, - SimulationPreparationError, -) +from app.simulation.native_codegen.runner import simulate_native from tests.test_system_xml_protocol import physical_port @@ -250,6 +247,26 @@ def xml_request(body: bytes) -> Request: class GenericSystemXmlSimulationTests(unittest.TestCase): + def test_native_chain_conserves_mass_energy_and_edge_order(self): + results = [] + for reverse in (False, True): + result = simulate_native(compile_reactflow_network(chain_project(reverse_edges=reverse)), SolveIVPConfig(t_stop=.01, max_step=.001), sample_step=.005) + self.assertTrue(result.success) + self.assertEqual(result.diagnostics['backend'], 'native-c') + self.assertLess(result.final['cylinder_1.p'], 500000) + self.assertGreater(result.final['tank_1.p'], 100000) + for field, tolerance in (('m', 1e-12), ('U', 1e-6)): + totals = [a+b for a,b in zip(result.series['cylinder_1.'+field], result.series['tank_1.'+field])] + self.assertLess(max(totals)-min(totals), tolerance) + results.append(result) + self.assertAlmostEqual(results[0].final['tank_1.p'], results[1].final['tank_1.p'], places=7) + + def test_native_branched_topology_conserves_flow(self): + result = simulate_native(compile_reactflow_network(branched_project()), SolveIVPConfig(t_stop=.005, max_step=.001), sample_step=.005) + self.assertTrue(result.success) + self.assertAlmostEqual(result.final['pipe_1.port_a.m_flow'], result.final['pipe_2.port_a.m_flow'], places=10) + self.assertGreater(result.final['tank_1.p'], 100000) + def test_xml_pipe_compiles_to_quasi_steady_resistance(self) -> None: network = compile_reactflow_network(chain_project()) @@ -259,166 +276,8 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): self.assertEqual(structure["equationCount"], 12) self.assertTrue(structure["isSquare"]) - def test_generic_chain_simulation_conserves_mass_and_moves_pressures(self) -> None: - network = compile_reactflow_network(chain_project()) - progress: list[tuple[float, str]] = [] - activity_tracker = SolverActivityTracker() - system = GenericFluidSystem(network) - result = system.simulate( - SolveIVPConfig( - t_start=0.0, - t_stop=0.01, - method="BDF", - max_step=0.001, - ), - sample_step=0.005, - progress_callback=lambda value, phase: progress.append((value, phase)), - activity_tracker=activity_tracker, - ) - self.assertTrue(result.success) - self.assertEqual(progress[0], (0.0, "initializing")) - self.assertEqual(progress[-1], (1.0, "complete")) - self.assertTrue( - all( - current[0] <= following[0] - for current, following in zip(progress, progress[1:]) - ) - ) - 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 = [ - cylinder + tank - for cylinder, tank in zip( - result.series["cylinder_1.m"], - result.series["tank_1.m"], - ) - ] - self.assertLess(max(total_mass) - min(total_mass), 1e-12) - total_energy = [ - cylinder + tank - for cylinder, tank in zip( - result.series["cylinder_1.U"], - result.series["tank_1.U"], - ) - ] - self.assertLess(max(total_energy) - min(total_energy), 1e-6) - pressure_flow = result.diagnostics["pressureFlow"] - self.assertLess(pressure_flow["maxScaledResidual"], 1e-7) - self.assertEqual( - pressure_flow["solveCount"], - pressure_flow["seededSolveCount"] - + pressure_flow["nonlinearSolveCount"], - ) - self.assertAlmostEqual( - pressure_flow["fastPathHitRate"], - pressure_flow["seededSolveCount"] / pressure_flow["solveCount"], - ) - self.assertGreaterEqual( - pressure_flow["residualEvaluationCount"], - 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() - - def request_cancel_after_progress(progress: float, phase: str) -> None: - if phase == "integrating" and progress >= 0.2: - cancel_event.set() - - with patch.dict( - os.environ, - {"SIMULATION_ODE_JACOBIAN_MODE": "optimized"}, - ): - result = GenericFluidSystem( - compile_reactflow_network(chain_project()) - ).simulate( - SolveIVPConfig(t_stop=0.05, method="BDF", max_step=0.001), - sample_step=0.005, - progress_callback=request_cancel_after_progress, - cancel_check=cancel_event.is_set, - ) - - self.assertFalse(result.success) - self.assertEqual(result.status, "cancelled") - self.assertGreaterEqual(result.diagnostics["sampleCount"], 2) - integration = result.diagnostics["integration"] - self.assertEqual(integration["method"], "BDF") - self.assertEqual(integration["segmentCount"], len(integration["segments"])) - self.assertEqual( - integration["totals"]["nfev"], - sum(segment["nfev"] for segment in integration["segments"]), - ) - sparsity = integration["jacobianSparsity"] - self.assertGreater(sparsity["nonzeroCount"], 0) - self.assertGreater(sparsity["colorGroupCount"], 0) - self.assertEqual( - integration["jacobian"]["mode"], - "scipySparseFiniteDifference", - ) - self.assertEqual( - integration["jacobian"]["fallbackReason"], - "denseStateDependencyPattern", - ) - self.assertEqual( - integration["totals"]["finiteDifferenceRhsEstimate"], - sum( - segment["finiteDifferenceRhsEstimate"] - for segment in integration["segments"] - ), - ) - self.assertGreater(result.simulated_until, 0.0) - self.assertLess(result.simulated_until, 0.05) - self.assertEqual( - len(result.series["time"]), - len(result.series["cylinder_1.p"]), - ) def test_task_registry_distinguishes_user_stop_and_stalled_stop(self) -> None: for reason, expected_status in (("user", "stopped"), ("stalled", "stalled")): @@ -446,63 +305,8 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): self.assertEqual(snapshot["cancelReason"], reason) self.assertEqual(snapshot["result"]["series"]["time"], [0.0, 0.1]) - def test_physical_edge_order_does_not_change_simulation(self) -> None: - forward = GenericFluidSystem( - compile_reactflow_network(chain_project()) - ).simulate( - SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001), - sample_step=0.005, - ) - reverse = GenericFluidSystem( - compile_reactflow_network(chain_project(reverse_edges=True)) - ).simulate( - SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001), - sample_step=0.005, - ) - self.assertAlmostEqual( - forward.final["tank_1.p"], - reverse.final["tank_1.p"], - places=7, - ) - def test_branched_topology_is_solved_without_fixed_testmodel_closure(self) -> None: - network = compile_reactflow_network(branched_project()) - result = GenericFluidSystem(network).simulate( - SolveIVPConfig(t_stop=0.005, method="BDF", max_step=0.001), - sample_step=0.005, - ) - - self.assertTrue(result.success) - self.assertAlmostEqual( - result.final["pipe_1.port_a.m_flow"], - result.final["pipe_2.port_a.m_flow"], - places=10, - ) - self.assertGreater(result.final["tank_1.p"], 100000.0) - - def test_directly_coupled_storage_components_are_rejected(self) -> None: - project = chain_project() - project.nodes = [project.nodes[0], project.nodes[-1]] - project.edges = [ - ReactFlowEdgePayload( - **physical_edge( - "edge-1", - "cylinder_1", - "port_b", - "tank_1", - "port_a", - ) - ) - ] - - with self.assertRaises(SimulationPreparationError) as caught: - GenericFluidSystem(compile_reactflow_network(project)) - - self.assertIn( - "IDEAL_STORAGE_COUPLING_UNSUPPORTED", - {issue.code for issue in caught.exception.issues}, - ) def test_raw_system_xml_runs_through_generic_simulation_endpoint(self) -> None: xml = build_reactflow_system_xml(chain_project()) @@ -582,7 +386,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): project = branched_project() project.simulation.t_stop = 2.0 project.simulation.step = 0.02 - project.simulation.max_step = 0.01 + project.simulation.max_step = 0.000001 xml = build_reactflow_system_xml(project) simulation_id = f"test-{uuid4().hex}" task = _register_simulation_task(simulation_id) @@ -704,7 +508,7 @@ class GenericSystemXmlSimulationTests(unittest.TestCase): self.assertEqual(caught.exception.status_code, 422) self.assertIn( - "IDEAL_STORAGE_COUPLING_UNSUPPORTED", + "SIMULATION_EXECUTION_FAILED", { issue["code"] for issue in caught.exception.detail["issues"] diff --git a/tests/test_ideal_pneumatic_storage_coupling.py b/tests/test_ideal_pneumatic_storage_coupling.py deleted file mode 100644 index 4b79844..0000000 --- a/tests/test_ideal_pneumatic_storage_coupling.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.amesim.flow.pipes import AmesimPnl0001, AmesimPnl0003 -from app.simulation.core.medium import IdealGasMedium -from app.simulation.solvers.solver import SolveIVPConfig -from app.simulation.systems.generic import GenericFluidSystem, simulation_preparation_issues -from app.simulation.systems.network import SimulationNetwork - - -class IdealPneumaticStorageCouplingTests(unittest.TestCase): - def build_network(self) -> SimulationNetwork: - medium = IdealGasMedium() - network = SimulationNetwork("ideal-pipe-compliance-coupling") - for component in ( - AmesimPnpl01("closed_1"), - AmesimPnl0001("pnl0001", medium, p0=15.3e6, T0=293.15), - AmesimPnl0003( - "pnl0003", - medium, - p1_0=15.3e6, - T1_0=293.15, - p2_0=15.3e6, - T2_0=293.15, - ), - AmesimPnpl01("closed_2"), - ): - network.add_component(component) - network.connect("closed_1", "port_1", "pnl0001", "port_1") - network.connect("pnl0001", "port_2", "pnl0003", "port_1") - network.connect("pnl0003", "port_2", "closed_2", "port_1") - return network - - def test_supported_amesim_pipe_compliances_are_not_rejected(self) -> None: - issues = simulation_preparation_issues(self.build_network()) - - self.assertNotIn( - "IDEAL_STORAGE_COUPLING_UNSUPPORTED", - {issue.code for issue in issues}, - ) - - def test_coupled_pipe_compliances_run_without_fictitious_resistance(self) -> None: - system = GenericFluidSystem(self.build_network()) - - result = system.simulate( - SolveIVPConfig(t_stop=0.001, method="BDF", max_step=0.0002), - sample_step=0.001, - ) - - self.assertTrue(result.success) - self.assertAlmostEqual(result.final["pnl0001.p"], 15.3e6, delta=1.0e-4) - self.assertAlmostEqual(result.final["pnl0003.p1"], 15.3e6, delta=1.0e-4) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_max_step_matrix.py b/tests/test_max_step_matrix.py deleted file mode 100644 index 2d36f2a..0000000 --- a/tests/test_max_step_matrix.py +++ /dev/null @@ -1,333 +0,0 @@ -from __future__ import annotations - -from contextlib import redirect_stdout -from io import StringIO -from pathlib import Path -import unittest -from unittest.mock import patch - -from app.simulation.benchmark_regression import ( - DEFAULT_MANIFEST_PATH, - RegressionCaseRequest, - RegressionManifestError, -) -from app.simulation.max_step_matrix import main, run_max_step_matrix - - -def _fake_completed( - request: RegressionCaseRequest, - *, - prefix_bias: float = 0.0, -) -> dict[str, object]: - signal_times = [value for value in (0.04, 0.8) if value <= request.stop_time] - checkpoints = [ - { - "requestedTime": checkpoint, - "actualTime": checkpoint, - "available": True, - "stateValues": { - "state.a": checkpoint + prefix_bias, - "state.b": 2.0 * checkpoint, - }, - } - for checkpoint in request.checkpoint_times - ] - event_trace = { - "signalEventTimes": signal_times, - "stateTransitionCount": 0, - "mechanicalTransitionTimes": [], - "mechanicalTransitionTimesAvailable": True, - } - return { - "outcome": "completed", - "orchestrationWallSeconds": 0.01, - "worker": { - "outcome": "completed", - "wallSeconds": 0.01, - "summary": { - "success": True, - "status": "completed", - "simulatedUntil": request.stop_time, - "checkpoints": checkpoints, - "physicalContract": { - "schemaVersion": 1, - "projectionCategories": ["state"], - "checkpoints": checkpoints, - "eventTrace": event_trace, - }, - "eventTrace": event_trace, - "diagnostics": { - "pressureFlow": {"maxScaledResidual": 1.0e-12}, - "integration": { - "totals": { - "nfev": int(100 * request.stop_time / request.max_step), - "njev": 4, - "nlu": 8, - "acceptedStepCount": int( - request.stop_time / request.max_step - ), - "solverStartCount": len(signal_times) + 1, - "stateTransitionCount": 0, - "recoverableRetryCount": 0, - } - }, - }, - }, - }, - } - - -def _fake_failure(request: RegressionCaseRequest) -> dict[str, object]: - return { - "outcome": "failed", - "worker": { - "outcome": "failed", - "summary": { - "success": False, - "status": "failed", - "simulatedUntil": 0.75 * request.stop_time, - }, - }, - } - - -class MaxStepMatrixTests(unittest.TestCase): - def test_single_cell_passes_when_no_comparison_is_required(self) -> None: - report = run_max_step_matrix( - DEFAULT_MANIFEST_PATH, - horizon_case_ids=("1s",), - max_steps=(0.02,), - expected_projection_count=2, - case_executor=_fake_completed, - ) - - comparisons = report["comparisons"] - self.assertEqual(comparisons["sameHorizonAcrossMaxSteps"], []) - self.assertEqual(comparisons["sameMaxStepAcrossHorizons"], []) - self.assertEqual(comparisons["evaluatedCount"], 0) - self.assertTrue(comparisons["passed"]) - self.assertTrue(report["acceptance"]["passed"]) - - with patch( - "app.simulation.max_step_matrix.run_max_step_matrix", - return_value=report, - ), redirect_stdout(StringIO()): - exit_code = main(["--horizon", "1s", "--max-step", "0.02"]) - self.assertEqual(exit_code, 0) - - def test_multiple_cells_still_fail_when_comparison_is_unavailable(self) -> None: - def execute(request: RegressionCaseRequest) -> dict[str, object]: - if request.max_step == 0.05: - return _fake_failure(request) - return _fake_completed(request) - - report = run_max_step_matrix( - DEFAULT_MANIFEST_PATH, - horizon_case_ids=("1s",), - max_steps=(0.01, 0.05), - expected_projection_count=2, - case_executor=execute, - ) - - comparisons = report["comparisons"] - records = comparisons["sameHorizonAcrossMaxSteps"] - self.assertEqual(len(records), 1) - self.assertFalse(records[0]["evaluated"]) - self.assertEqual(records[0]["reason"], "oneOrBothCasesDidNotComplete") - self.assertFalse(comparisons["passed"]) - self.assertFalse(report["acceptance"]["passed"]) - - with patch( - "app.simulation.max_step_matrix.run_max_step_matrix", - return_value=report, - ), redirect_stdout(StringIO()): - exit_code = main( - [ - "--horizon", - "1s", - "--max-step", - "0.01", - "--max-step", - "0.05", - ] - ) - self.assertEqual(exit_code, 1) - - def test_custom_horizon_and_common_prefix_matrix(self) -> None: - source = Path("tests/data/test-mql-8.xml") - source_before = source.read_bytes() - requests: list[RegressionCaseRequest] = [] - - def execute(request: RegressionCaseRequest) -> dict[str, object]: - requests.append(request) - return _fake_completed(request) - - report = run_max_step_matrix( - DEFAULT_MANIFEST_PATH, - horizon_case_ids=("1s", 2.0), - max_steps=(0.01, 0.05), - additional_checkpoint_times=(1.08, 1.10), - expected_projection_count=2, - case_executor=execute, - ) - - self.assertEqual(source.read_bytes(), source_before) - self.assertEqual(len(requests), 4) - self.assertEqual( - [(request.stop_time, request.max_step) for request in requests], - [(1.0, 0.01), (1.0, 0.05), (2.0, 0.01), (2.0, 0.05)], - ) - custom_requests = [request for request in requests if request.stop_time == 2.0] - self.assertEqual( - custom_requests[0].checkpoint_times, - (0.0, 0.04, 0.8, 1.0, 1.08, 1.1, 2.0), - ) - self.assertEqual( - report["configuration"]["horizons"][1]["horizonCaseId"], - "2s-custom", - ) - self.assertEqual( - len(report["comparisons"]["sameHorizonAcrossMaxSteps"]), 2 - ) - self.assertEqual( - len(report["comparisons"]["sameMaxStepAcrossHorizons"]), 2 - ) - self.assertTrue(report["comparisons"]["passed"]) - self.assertTrue(report["acceptance"]["passed"]) - - def test_failed_tier_defers_every_later_cell(self) -> None: - requests: list[RegressionCaseRequest] = [] - - def execute(request: RegressionCaseRequest) -> dict[str, object]: - requests.append(request) - if request.max_step == 0.05: - return _fake_failure(request) - return _fake_completed(request) - - report = run_max_step_matrix( - DEFAULT_MANIFEST_PATH, - horizon_case_ids=("1s", "5s", "10s"), - max_steps=(0.01, 0.05), - expected_projection_count=2, - case_executor=execute, - ) - - self.assertEqual(len(requests), 2) - self.assertEqual(report["acceptance"]["deferredCellCount"], 4) - self.assertEqual( - [case["outcome"] for case in report["cases"][2:]], - ["deferred"] * 4, - ) - self.assertEqual( - report["tierDecisions"][1]["reason"], "previousTierDidNotPass" - ) - - def test_tstop_dependent_prefix_is_reported(self) -> None: - def execute(request: RegressionCaseRequest) -> dict[str, object]: - return _fake_completed( - request, - prefix_bias=0.1 if request.stop_time > 1.0 else 0.0, - ) - - report = run_max_step_matrix( - DEFAULT_MANIFEST_PATH, - horizon_case_ids=("1s", 2.0), - max_steps=(0.01, 0.05), - expected_projection_count=2, - stop_after_failed_tier=False, - case_executor=execute, - ) - - prefix_comparisons = report["comparisons"][ - "sameMaxStepAcrossHorizons" - ] - self.assertEqual(len(prefix_comparisons), 2) - self.assertTrue(all(item["evaluated"] for item in prefix_comparisons)) - self.assertTrue(all(not item["passed"] for item in prefix_comparisons)) - self.assertGreater( - prefix_comparisons[0]["stateProjection"]["valueMismatchCount"], 0 - ) - - def test_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" - ): - run_max_step_matrix( - DEFAULT_MANIFEST_PATH, - horizon_case_ids=(2.0, "1s"), - max_steps=(0.01, 0.05), - expected_projection_count=2, - case_executor=_fake_completed, - ) - - 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, - horizon_case_ids=(2.0,), - max_steps=(0.01, 0.05), - soft_timeout_seconds=20.0, - hard_timeout_seconds=10.0, - expected_projection_count=2, - case_executor=_fake_completed, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_mechanical_solver_causalization.py b/tests/test_mechanical_solver_causalization.py deleted file mode 100644 index 91d69c5..0000000 --- a/tests/test_mechanical_solver_causalization.py +++ /dev/null @@ -1,716 +0,0 @@ -from __future__ import annotations - -from math import exp, isfinite -import unittest - -from app.simulation.components.amesim.mechanical.translational import ( - AmesimF000, - AmesimForc, - AmesimLstp00a, - AmesimMecmas21, -) -from app.simulation.core.base import AlgebraicComponent -from app.simulation.core.equations import EquationResidual -from app.simulation.core.medium import IdealGasMedium -from app.simulation.core.ports import PortDefinition -from app.simulation.solvers.algebraic import PressureFlowSolver -from app.simulation.solvers.mechanical import MechanicalConstraintGroup -from app.simulation.solvers.solver import SolveIVPConfig -from app.simulation.systems.generic import GenericFluidSystem -from app.simulation.systems.network import SimulationNetwork - - -class _AnchoredMechanicalForce(AlgebraicComponent): - """Test boundary whose modest force must survive unrelated large scales.""" - - PORTS = (PortDefinition.mechanical_translational("port_1"),) - - def __init__(self, name: str, force: float) -> None: - super().__init__(name=name) - self.force = float(force) - self.port_1 = self.register_declared_port("port_1") - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:x_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_1.x",), - role="effort", - value=self.port_1.x, - ), - EquationResidual( - id=f"{self.name}:v_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_1.v",), - role="effort", - value=self.port_1.v, - ), - EquationResidual( - id=f"{self.name}:force_state", - owner="component", - owner_id=self.name, - relation="state", - variables=(f"{self.name}.port_1.f",), - role="flow", - value=self.port_1.f - self.force, - ), - ) - - -class _RigidMechanicalLink(AlgebraicComponent): - """Massless link whose position constraint supplies the rigid coordinate.""" - - PORTS = ( - PortDefinition.mechanical_translational("port_1"), - PortDefinition.mechanical_translational("port_2"), - ) - - def __init__(self, name: str) -> None: - super().__init__(name=name) - self.port_1 = self.register_declared_port("port_1") - self.port_2 = self.register_declared_port("port_2") - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:x_equal", - owner="component", - owner_id=self.name, - relation="equal", - variables=(f"{self.name}.port_1.x", f"{self.name}.port_2.x"), - role="effort", - value=self.port_1.x - self.port_2.x, - ), - EquationResidual( - id=f"{self.name}:v_equal", - owner="component", - owner_id=self.name, - relation="equal", - variables=(f"{self.name}.port_1.v", f"{self.name}.port_2.v"), - role="effort", - value=self.port_1.v - self.port_2.v, - ), - ) - - -def _single_mass_system( - applied_force: float, - *, - stoptype: float = 4.0, - x0: float = 0.0, - xmin: float = -1.0, - xmax: float = 1.0, -) -> tuple[GenericFluidSystem, AmesimMecmas21]: - medium = IdealGasMedium() - source = AmesimForc("force") - source.res.signal = applied_force - mass = AmesimMecmas21( - "mass", - medium, - mass=2.0, - useFriction=1.0, - stoptype=stoptype, - x0=x0, - v0=0.0, - xmin=xmin, - xmax=xmax, - ) - zero = AmesimF000("zero") - - network = SimulationNetwork("single-mass-causalization") - for component in (source, mass, zero): - network.add_component(component) - network.connect("force", "port_2", "mass", "port_1") - network.connect("mass", "port_2", "zero", "port_1") - return GenericFluidSystem(network), mass - - -def _high_stiffness_contact_impact_system() -> GenericFluidSystem: - """Return the reduced mechanical core of the four-branch stop impact. - - The production model has four 50 kg piston groups coupled through LSTP00A - contacts to one 170000 kg support group. When the support reaches its - ideal upper stop, its velocity is reset while each contact still carries - the finite, extremely fast LSTP damping transient. One branch is enough - to retain that stiffness and state-transition interaction in a fast test. - """ - - medium = IdealGasMedium() - zero_left = AmesimF000("zero_left") - moving_mass = AmesimMecmas21( - "moving_mass", - medium, - mass=50.0, - useFriction=1.0, - stoptype=4.0, - x0=0.0090025, - v0=1.0, - ) - contact = AmesimLstp00a( - "contact", - medium, - gap0=0.0, - kcont=1.0e11, - rcont=1.0e11, - Pdis=1.0e-7, - discContactOption=1.0, - ) - support_mass = AmesimMecmas21( - "support_mass", - medium, - mass=170000.0, - useFriction=1.0, - stoptype=1.0, - xmin=0.0, - xmax=0.01, - x0=0.009, - v0=1.0, - ) - zero_right = AmesimF000("zero_right") - - network = SimulationNetwork("high-stiffness-contact-impact") - for component in ( - zero_left, - moving_mass, - contact, - support_mass, - zero_right, - ): - network.add_component(component) - network.connect("zero_left", "port_1", "moving_mass", "port_1") - network.connect("moving_mass", "port_2", "contact", "port_1") - network.connect("contact", "port_2", "support_mass", "port_1") - network.connect("support_mass", "port_2", "zero_right", "port_1") - return GenericFluidSystem(network) - - -class MechanicalSolverCausalizationTests(unittest.TestCase): - def test_high_stiffness_lstp_survives_ideal_stop_transition(self) -> None: - system = _high_stiffness_contact_impact_system() - - result = system.simulate( - SolveIVPConfig( - t_start=0.0, - t_stop=0.002, - max_step=0.002, - method="BDF", - rtol=1.0e-6, - ), - sample_step=0.00025, - ) - - self.assertTrue(result.success, result.message) - totals = result.diagnostics["integration"]["totals"] - self.assertEqual(totals["stateTransitionCount"], 1) - self.assertEqual(totals["solverStartCount"], 2) - self.assertEqual(totals["recoverableRetryCount"], 0) - - self.assertAlmostEqual(result.series["support_mass.x"][-1], 0.01) - self.assertAlmostEqual(result.series["support_mass.v"][-1], 0.0, delta=1.0e-9) - self.assertAlmostEqual(result.series["moving_mass.v"][-1], 0.0, delta=1.0e-4) - self.assertGreater(max(result.series["contact.force"]), 1.0e10) - for values in result.series.values(): - self.assertTrue(all(isfinite(value) for value in values)) - - def test_lstp_contact_uses_exponential_damping_ramp_and_negative_force_option( - self, - ) -> None: - medium = IdealGasMedium() - contact = AmesimLstp00a( - "contact", - medium, - gap0=0.0, - kcont=100.0, - rcont=10.0, - Pdis=0.1, - discContactOption=1.0, - ) - contact.port_1.x = 0.1 - contact.port_2.x = 0.0 - contact.port_1.v = -2.0 - contact.port_2.v = 0.0 - - expected = 10.0 - 20.0 * (1.0 - exp(-1.0)) - self.assertAlmostEqual(contact.contact_force, expected, places=12) - - clipped = AmesimLstp00a( - "clipped_contact", - medium, - gap0=0.0, - kcont=100.0, - rcont=10.0, - Pdis=0.1, - discContactOption=2.0, - ) - clipped.port_1.x = contact.port_1.x - clipped.port_2.x = contact.port_2.x - clipped.port_1.v = contact.port_1.v - clipped.port_2.v = contact.port_2.v - self.assertEqual(clipped.contact_force, 0.0) - - contact.set_causal_contact(penetration=0.1, force=expected) - self.assertAlmostEqual(contact.contact_force, expected, places=12) - clipped.set_causal_contact(penetration=0.1, force=expected) - self.assertEqual(clipped.contact_force, 0.0) - - def test_causal_contact_survives_unrelated_nonlinear_fallback(self) -> None: - medium = IdealGasMedium() - source = AmesimForc("contact_force") - source.res.signal = 40.0 - contact = AmesimLstp00a( - "contact", - medium, - gap0=0.0, - kcont=1.0e11, - rcont=0.0, - Pdis=1.0e-7, - discContactOption=1.0, - ) - mass = AmesimMecmas21( - "mass", - medium, - mass=2.0, - useFriction=1.0, - x0=1.0e9, - ) - zero = AmesimF000("zero") - unrelated = _AnchoredMechanicalForce("unrelated", 7.0) - - network = SimulationNetwork("causal-contact-with-nonlinear-fallback") - for component in (source, contact, mass, zero, unrelated): - network.add_component(component) - network.connect("contact_force", "port_2", "contact", "port_1") - network.connect("contact", "port_2", "mass", "port_1") - network.connect("mass", "port_2", "zero", "port_1") - - diagnostics = PressureFlowSolver(network).solve() - - self.assertTrue(diagnostics.success, diagnostics.message) - self.assertGreater(diagnostics.evaluations, 0) - self.assertAlmostEqual(contact.penetration, 4.0e-10, places=20) - self.assertAlmostEqual(contact.contact_force, 40.0, places=8) - self.assertAlmostEqual(unrelated.port_1.f, 7.0, places=8) - - def test_elastic_mass_endstop_applies_contact_force_option(self) -> None: - medium = IdealGasMedium() - parameters = { - "mass": 2.0, - "useFriction": 1.0, - "stoptype": 2.0, - "x0": 0.1, - "xmax": 0.0, - "Kbmax": 100.0, - "Dbmax": 10.0, - "Pdmax": 0.01, - "v0": -2.0, - } - negative_allowed = AmesimMecmas21( - "negative_allowed", - medium, - discContactOption=1.0, - **parameters, - ) - clipped = AmesimMecmas21( - "clipped", - medium, - discContactOption=2.0, - **parameters, - ) - - self.assertAlmostEqual(negative_allowed._upper_limit_force(), -10.0) - self.assertAlmostEqual(negative_allowed.acceleration(), 5.0) - self.assertEqual(clipped._upper_limit_force(), 0.0) - self.assertEqual(clipped.acceleration(), 0.0) - - def test_large_explicit_force_does_not_mask_small_local_force_residual(self) -> None: - medium = IdealGasMedium() - source = AmesimForc("large_force") - source.res.signal = 1.0e17 - mass = AmesimMecmas21( - "large_mass", - medium, - mass=90_000.0, - useFriction=1.0, - ) - zero = AmesimF000("large_zero") - local_force = _AnchoredMechanicalForce("local_force", 40.0) - - network = SimulationNetwork("large-and-local-force-scales") - for component in (source, mass, zero, local_force): - network.add_component(component) - network.connect("large_force", "port_2", "large_mass", "port_1") - network.connect("large_mass", "port_2", "large_zero", "port_1") - - diagnostics = PressureFlowSolver(network).solve() - - self.assertTrue(diagnostics.success) - self.assertEqual(source.port_2.f, -1.0e17) - self.assertEqual(mass.port_1.f, 1.0e17) - self.assertAlmostEqual(local_force.port_1.f, 40.0, places=9) - residuals = { - equation.id: equation.value - for equation in network.pressure_flow_equation_residuals() - } - self.assertLess(abs(residuals["local_force:force_state"]), 1.0e-9) - - def test_rigidly_connected_masses_share_state_and_acceleration(self) -> None: - medium = IdealGasMedium() - source = AmesimForc("force") - source.res.signal = 100.0 - first_mass = AmesimMecmas21( - "first_mass", - medium, - mass=2.0, - useFriction=1.0, - x0=0.25, - v0=0.5, - ) - second_mass = AmesimMecmas21( - "second_mass", - medium, - mass=3.0, - useFriction=1.0, - x0=0.25, - v0=0.5, - ) - link = _RigidMechanicalLink("rigid_link") - zero = AmesimF000("zero") - - network = SimulationNetwork("rigid-mass-group") - for component in (source, first_mass, link, second_mass, zero): - network.add_component(component) - network.connect("force", "port_2", "first_mass", "port_1") - network.connect("first_mass", "port_2", "rigid_link", "port_1") - network.connect("rigid_link", "port_2", "second_mass", "port_1") - network.connect("second_mass", "port_2", "zero", "port_1") - - system = GenericFluidSystem(network) - initial_state = system.consistent_initial_state_vector() - derivatives = system.rhs(0.0, initial_state) - - self.assertEqual(len(initial_state), 2) - self.assertEqual(initial_state, [0.5, 0.25]) - self.assertAlmostEqual(derivatives[0], 20.0, places=12) - self.assertAlmostEqual(first_mass.acceleration(), 20.0, places=12) - self.assertAlmostEqual(second_mass.acceleration(), 20.0, places=12) - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001), - sample_step=0.005, - ) - - self.assertTrue(result.success, result.message) - self.assertEqual(result.series["first_mass.x"], result.series["second_mass.x"]) - self.assertEqual(result.series["first_mass.v"], result.series["second_mass.v"]) - self.assertEqual(result.series["first_mass.a"], result.series["second_mass.a"]) - for acceleration in result.series["first_mass.a"]: - self.assertAlmostEqual(acceleration, 20.0, places=9) - - def test_ideal_upper_stop_locks_mass_under_outward_force(self) -> None: - system, _mass = _single_mass_system( - 100.0, - stoptype=1.0, - x0=0.0, - xmin=-1.0, - xmax=0.0, - ) - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001), - sample_step=0.005, - ) - - self.assertTrue(result.success, result.message) - for value in result.series["mass.x"]: - self.assertAlmostEqual(value, 0.0, places=12) - for value in result.series["mass.v"]: - self.assertAlmostEqual(value, 0.0, places=12) - for value in result.series["mass.a"]: - self.assertAlmostEqual(value, 0.0, places=12) - # Ideal-contact reaction is an internal constraint force. AMESim's - # Fmax output is reserved for the elastic (stoptype=2) endstop. - self.assertEqual(result.series["mass.Fmax"], [0.0, 0.0, 0.0]) - - def test_ideal_upper_stop_releases_mass_under_inward_force(self) -> None: - system, _mass = _single_mass_system( - -100.0, - stoptype=1.0, - x0=0.0, - xmin=-1.0, - xmax=0.0, - ) - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001), - sample_step=0.005, - ) - - self.assertTrue(result.success, result.message) - self.assertAlmostEqual(result.series["mass.a"][0], -50.0, places=12) - self.assertEqual(result.series["mass.Fmax"], [0.0, 0.0, 0.0]) - self.assertLess(result.series["mass.v"][-1], 0.0) - self.assertLess(result.series["mass.x"][-1], 0.0) - - def test_ideal_upper_stop_releases_subthreshold_inward_velocity(self) -> None: - system, mass = _single_mass_system( - 100.0, - stoptype=1.0, - x0=0.0, - xmin=-1.0, - xmax=0.0, - ) - mass.v = -0.5 * mass.dvel - mass.refresh_thermodynamic_ports() - initial_state = system.consistent_initial_state_vector() - - derivatives = system.rhs(0.0, initial_state) - - self.assertAlmostEqual(derivatives[0], 50.0, places=12) - self.assertAlmostEqual(derivatives[1], -0.5 * mass.dvel, places=18) - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=1.0e-6, max_step=1.0e-6), - sample_step=5.0e-9, - ) - - self.assertTrue(result.success, result.message) - self.assertAlmostEqual(result.series["mass.v"][0], -0.5 * mass.dvel) - self.assertLess(min(result.series["mass.x"]), 0.0) - self.assertLessEqual(max(result.series["mass.x"]), 1.0e-15) - self.assertAlmostEqual(result.series["mass.x"][-1], 0.0, delta=5.0e-15) - self.assertAlmostEqual(result.series["mass.v"][-1], 0.0, delta=1.1e-12) - - def test_ideal_upper_stop_ignores_jacobian_scale_inward_velocity_noise(self) -> None: - system, mass = _single_mass_system( - 100.0, - stoptype=1.0, - x0=0.0, - xmin=-1.0, - xmax=0.0, - ) - initial_state = system.consistent_initial_state_vector() - perturbed_state = [-1.0e-14, initial_state[1]] - - self.assertEqual(system.rhs(0.0, initial_state), [0.0, 0.0]) - self.assertEqual(system.rhs(0.0, perturbed_state), [0.0, 0.0]) - self.assertEqual(mass.v, -1.0e-14) - - def test_ideal_lower_stop_ignores_jacobian_scale_outward_velocity_noise(self) -> None: - system, mass = _single_mass_system( - -100.0, - stoptype=1.0, - x0=0.0, - xmin=0.0, - xmax=1.0, - ) - initial_state = system.consistent_initial_state_vector() - perturbed_state = [1.0e-14, initial_state[1]] - - self.assertEqual(system.rhs(0.0, initial_state), [0.0, 0.0]) - self.assertEqual(system.rhs(0.0, perturbed_state), [0.0, 0.0]) - self.assertEqual(mass.v, 1.0e-14) - - def test_ideal_stop_rejects_initial_position_outside_limits(self) -> None: - system, _mass = _single_mass_system( - 100.0, - stoptype=1.0, - x0=0.01, - xmin=-1.0, - xmax=0.0, - ) - - with self.assertRaisesRegex(ValueError, "outside the discrete endstop limits"): - system.consistent_initial_state_vector() - - def test_rhs_trial_state_does_not_commit_ideal_stop_mode(self) -> None: - system, _mass = _single_mass_system( - 100.0, - stoptype=1.0, - x0=0.0, - xmin=-1.0, - xmax=0.01, - ) - initial_state = system.consistent_initial_state_vector() - group = system.mechanical_state_reducer.groups[0] - committed_mode = group.mode - - trial_derivatives = system.rhs(0.0, [0.0, 0.02]) - - self.assertEqual(trial_derivatives, [0.0, 0.0]) - self.assertEqual(group.mode, committed_mode) - - accepted_derivatives = system.rhs(0.0, initial_state) - - self.assertEqual(group.mode, committed_mode) - self.assertAlmostEqual(accepted_derivatives[0], 50.0, places=12) - self.assertAlmostEqual(accepted_derivatives[1], 0.0, places=12) - - def test_ideal_upper_stop_projects_outward_velocity_at_step_start(self) -> None: - system, mass = _single_mass_system( - 100.0, - stoptype=1.0, - x0=0.01, - xmin=-1.0, - xmax=0.01, - ) - mass.v = 1.0 - mass.refresh_thermodynamic_ports() - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=0.005, max_step=0.001), - sample_step=0.001, - ) - - self.assertTrue(result.success, result.message) - for value in result.series["mass.x"]: - self.assertAlmostEqual(value, 0.01, places=12) - for value in result.series["mass.v"]: - self.assertAlmostEqual(value, 0.0, places=12) - for value in result.series["mass.a"]: - self.assertAlmostEqual(value, 0.0, places=12) - - def test_ideal_stop_ignores_zero_velocity_roundoff_inside_boundary_band(self) -> None: - system, _mass = _single_mass_system( - 0.0, - stoptype=1.0, - x0=0.0, - xmin=0.0, - xmax=1.0, - ) - reducer = system.mechanical_state_reducer - previous_state = [0.0, 2.0e-32] - current_state = [0.0, -1.0e-32] - - def dense_state(time: float) -> list[float]: - fraction = float(time) - return [ - 0.0, - previous_state[1] - + fraction * (current_state[1] - previous_state[1]), - ] - - transition = reducer.state_transition( - 0.0, - previous_state, - 1.0, - current_state, - dense_state, - ) - - self.assertIsNone(transition) - - def test_ideal_upper_stop_projects_a_high_speed_impact(self) -> None: - system, _mass = _single_mass_system( - 100.0, - stoptype=1.0, - x0=0.0, - xmin=-1.0, - xmax=0.01, - ) - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=0.04, max_step=0.01), - sample_step=0.005, - ) - - self.assertTrue(result.success, result.message) - self.assertLessEqual(max(result.series["mass.x"]), 0.01 + 1.0e-12) - after_impact = [ - index - for index, time in enumerate(result.series["time"]) - if time >= 0.02 - 1.0e-10 - ] - self.assertTrue(after_impact) - for index in after_impact: - self.assertAlmostEqual(result.series["mass.x"][index], 0.01, places=12) - self.assertAlmostEqual(result.series["mass.v"][index], 0.0, places=12) - self.assertAlmostEqual(result.series["mass.a"][index], 0.0, places=12) - - def test_restitution_upper_stop_rebounds_a_high_speed_impact(self) -> None: - system, mass = _single_mass_system( - 0.0, - stoptype=3.0, - x0=-0.01, - xmin=-1.0, - xmax=0.0, - ) - mass.v = 2.0 - mass.restdvel = 0.1 - mass.restcoeff = 0.25 - mass.refresh_thermodynamic_ports() - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=0.012, max_step=0.01), - sample_step=0.002, - ) - - self.assertTrue(result.success, result.message) - self.assertLessEqual(max(result.series["mass.x"]), 1.0e-12) - rebound_indices = [ - index - for index, velocity in enumerate(result.series["mass.v"]) - if velocity < 0.0 - ] - self.assertTrue(rebound_indices) - for index in rebound_indices: - self.assertAlmostEqual(result.series["mass.v"][index], -0.5, places=12) - self.assertAlmostEqual(result.series["mass.x"][-1], -0.0035, places=10) - - def test_restitution_upper_stop_locks_at_velocity_threshold(self) -> None: - system, mass = _single_mass_system( - 100.0, - stoptype=3.0, - x0=0.0, - xmin=-1.0, - xmax=0.0, - ) - mass.restdvel = 0.1 - mass.restcoeff = 0.8 - mass.v = mass.restdvel - mass.refresh_thermodynamic_ports() - - result = system.simulate( - SolveIVPConfig(t_start=0.0, t_stop=0.01, max_step=0.001), - sample_step=0.005, - ) - - self.assertTrue(result.success, result.message) - for value in result.series["mass.x"]: - self.assertAlmostEqual(value, 0.0, places=12) - for value in result.series["mass.v"]: - self.assertAlmostEqual(value, 0.0, places=12) - for value in result.series["mass.a"]: - self.assertAlmostEqual(value, 0.0, places=12) - - def test_plastic_stop_wins_over_restitution_at_shared_boundary(self) -> None: - medium = IdealGasMedium() - plastic = AmesimMecmas21( - "plastic", - medium, - stoptype=1.0, - xmin=-1.0, - xmax=0.0, - useFriction=1.0, - ) - restitution = AmesimMecmas21( - "restitution", - medium, - stoptype=3.0, - xmin=-1.0, - xmax=0.0, - restdvel=0.1, - restcoeff=0.8, - useFriction=1.0, - ) - group = MechanicalConstraintGroup((plastic, restitution)) - - self.assertEqual(group.impact_velocity("upper", 2.0), 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_mql_full_branches_regression.py b/tests/test_mql_full_branches_regression.py deleted file mode 100644 index c647b69..0000000 --- a/tests/test_mql_full_branches_regression.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -from pathlib import Path -import unittest - -from app.main import compile_system_xml_network -from app.simulation.benchmark_regression import ( - evaluate_regression_golden, - load_regression_manifest, - run_regression_suite, - source_simulation_config, -) -from app.simulation.systems.generic import GenericFluidSystem -from app.system_xml import validate_system_xml_document - - -MANIFEST_PATH = Path( - "tests/baselines/simulation/test_mql_full_branches/manifest.json" -) -LONG_RUN_ENVIRONMENT = "RUN_TEST_MQL_FULL_BRANCHES_LONG_REGRESSION" - - -class MqlFullBranchesStaticRegressionTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.manifest = load_regression_manifest(MANIFEST_PATH) - cls.source_path = Path(cls.manifest["_sourcePath"]) - cls.source_payload = cls.source_path.read_bytes() - cls.validation = validate_system_xml_document(cls.source_payload) - - def test_authoritative_xml_hash_and_simulation_settings_are_fixed(self) -> None: - source = self.manifest["source"] - - self.assertEqual( - hashlib.sha256(self.source_payload).hexdigest(), source["sha256"] - ) - self.assertEqual(len(self.source_payload), source["bytes"]) - self.assertEqual( - source_simulation_config(self.source_payload), source["simulation"] - ) - - def test_structure_snapshot_covers_the_historical_solver_shape(self) -> None: - self.assertTrue(self.validation.valid, self.validation.as_dict()) - assert self.validation.document is not None - network = compile_system_xml_network(self.validation.document) - system = GenericFluidSystem(network) - expected = self.manifest["structure"] - pressure_flow = network.pressure_flow_structure_dict() - causal_execution = system.pressure_flow_solver.causal_execution_diagnostics() - jacobian = system.jacobian_sparsity_diagnostics() - - self.assertEqual(len(network.components), expected["componentCount"]) - self.assertEqual(len(network.connections), expected["connectionCount"]) - self.assertEqual( - len(network.dynamic_components()), expected["dynamicComponentCount"] - ) - self.assertEqual( - sum(component.state_size for component in network.dynamic_components()), - expected["stateCount"], - ) - self.assertEqual( - len(network.result_variable_metadata()), expected["resultVariableCount"] - ) - self.assertEqual( - pressure_flow["unknownCount"], expected["pressureFlowUnknownCount"] - ) - self.assertEqual( - pressure_flow["equationCount"], expected["pressureFlowEquationCount"] - ) - self.assertEqual(pressure_flow["isSquare"], expected["pressureFlowIsSquare"]) - for key in ( - "logicalEffortCoordinateCount", - "eliminatedEffortAliasCount", - "canonicalCoordinateCount", - "compatibilityScatterCount", - ): - self.assertEqual(causal_execution[key], expected[key]) - self.assertEqual(jacobian["nonzeroCount"], expected["jacobianNonzeroCount"]) - self.assertEqual( - jacobian["colorGroupCount"], expected["jacobianColorGroupCount"] - ) - self.assertEqual( - system.mechanical_state_reducer.has_state_events, - expected["hasMechanicalStateEvents"], - ) - - def test_three_production_repeats_replay_approved_goldens_exactly(self) -> None: - repository_root = Path(self.manifest["_repositoryRoot"]) - evidence = self.manifest["acceptanceEvidence"] - self.assertEqual(evidence["status"], "approved") - self.assertEqual(evidence["lane"], "production") - self.assertEqual(evidence["repeatCount"], 3) - self.assertTrue(evidence["physicalValuesIdenticalAcrossRepeats"]) - - expected_counts = {"0.81s": 480, "2.10s": 720} - stable_signatures: dict[str, object] = {} - for report_reference in evidence["reports"]: - report_path = repository_root / report_reference["path"] - payload = report_path.read_bytes() - self.assertEqual(len(payload), report_reference["bytes"]) - self.assertEqual( - hashlib.sha256(payload).hexdigest(), - report_reference["sha256"], - ) - report = json.loads(payload) - self.assertEqual(report["lane"], "production") - self.assertEqual( - report["source"]["sha256"], self.manifest["source"]["sha256"] - ) - - for case_id in self.manifest["sequence"]: - case = next( - item for item in report["cases"] if item["caseId"] == case_id - ) - self.assertEqual(case["outcome"], "completed") - self.assertTrue(case["acceptance"]["passed"]) - summary = case["worker"]["summary"] - golden = self.manifest["_goldens"][case_id]["production"] - audit = evaluate_regression_golden(summary, golden) - self.assertTrue(audit["passed"], audit) - self.assertEqual( - audit["comparedValueCount"], expected_counts[case_id] - ) - self.assertEqual(audit["maxAbsoluteError"], 0.0) - self.assertEqual(audit["maxToleranceRatio"], 0.0) - self.assertEqual(audit["issues"], []) - - diagnostics = dict(summary["diagnostics"]) - diagnostics.pop("performance", None) - signature = { - "physicalContract": summary["physicalContract"], - "eventTrace": summary["eventTrace"], - "outputContract": summary["outputContract"], - "diagnosticsWithoutPerformance": diagnostics, - } - if case_id not in stable_signatures: - stable_signatures[case_id] = signature - else: - self.assertEqual(stable_signatures[case_id], signature) - - def test_both_horizons_have_the_expected_signal_schedule(self) -> None: - assert self.validation.document is not None - system = GenericFluidSystem( - compile_system_xml_network(self.validation.document) - ) - - for case_id in self.manifest["sequence"]: - variant = self.manifest["variants"][case_id] - actual = system.signal_resolver.event_times( - 0.0, float(variant["stopTime"]) - ) - self.assertEqual(actual, tuple(variant["expectedSignalEventTimes"])) - - -@unittest.skipUnless( - os.getenv(LONG_RUN_ENVIRONMENT, "").strip().lower() in {"1", "true", "yes"}, - f"Set {LONG_RUN_ENVIRONMENT}=1 to run bounded 0.81/2.10 s integration.", -) -class MqlFullBranchesLongRegressionTests(unittest.TestCase): - def test_both_horizons_complete_within_their_safety_budgets(self) -> None: - report = run_regression_suite(MANIFEST_PATH, lane="production") - outcomes = [case["outcome"] for case in report["cases"]] - self.assertEqual(outcomes, ["completed", "completed"], report["cases"]) - self.assertTrue( - all( - case["acceptance"]["regressionGolden"]["passed"] - for case in report["cases"] - ), - report["cases"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_native_catalog.py b/tests/test_native_catalog.py new file mode 100644 index 0000000..32938d0 --- /dev/null +++ b/tests/test_native_catalog.py @@ -0,0 +1,113 @@ +"""Native catalog regression against frozen, independent Python results.""" +import json +import subprocess +import unittest +import numpy as np +from app.simulation.native_codegen.extended import catalog_contracts, compile_extended_program +from app.simulation.native_codegen.compiler import compile_native_program +from app.simulation.native_codegen.build import build_native, toolchain +from app.simulation.systems.network import SimulationNetwork +from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium +from tests.native_reference import reference_data, reference_network + +class Circuit: + def __init__(self, medium=None): + self.medium=medium or AmesimHeliumPengRobinsonMedium() + self.net=SimulationNetwork('native_catalog') + + def add(self, kind, name, **params): + cls=catalog_contracts()[kind] + values={p.name:p.default for p in cls.PARAMETERS};values.update(params) + c=cls.create(name=name,medium=self.medium,parameters=values) + self.net.add_component(c) + return c + + def connect(self,a,ap,b,bp): + self.net.connect(a.name,ap,b.name,bp) + + def chamber(self,name,**params): + return self.add('amesim_pnch023',name,**params) + + def seal(self): + connected={e.key for edge in self.net.connections for e in edge.endpoints} + for c in list(self.net.components.values()): + for p in c.active_port_definitions: + if p.domain=='pneumatic' and (c.name,p.name) not in connected: + cap=self.add('amesim_pnpl01','cap_'+str(len(self.net.components))) + self.connect(c,p.name,cap,'port_1') + return self.net + +class NativeCatalogTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + try: + toolchain() + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + raise unittest.SkipTest(f'Native toolchain unavailable: {exc}') + + def test_frozen_python_reference(self): + data = reference_data() + for index, case in enumerate(data['cases']): + with self.subTest(network=index): + net = reference_network(case) + # Exercise the optimized compiler as well as the general catalog. + programs = [compile_extended_program(net)] + if index >= len(data['cases']) - 3: + programs.append(compile_native_program(net)) + for program in programs: + build = build_native(program) + state_order = [case['stateKeys'].index(key) for key in program.state_keys] + output_order = [case['outputKeys'].index(v.key) for v in program.variables] + initial = json.loads(subprocess.run([str(build.executable), '--init'], capture_output=True, text=True, check=True, timeout=15).stdout) + np.testing.assert_allclose(initial, [case['initialState'][i] for i in state_order], rtol=data['rtol'], atol=data['atol']) + inputs = [' '.join(format(x, '.17g') for x in (probe['time'], *[probe['state'][i] for i in state_order])) for probe in case['probes']] + result = subprocess.run([str(build.executable), '--probe'], input='\n'.join(inputs)+'\n', capture_output=True, text=True, check=True, timeout=30) + rows = [json.loads(line) for line in result.stdout.splitlines()] + self.assertEqual(len(rows), len(inputs)) + for row, probe in zip(rows, case['probes']): + self.assertTrue(row['success']) + np.testing.assert_allclose(row['rhs'], [probe['rhs'][i] for i in state_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} RHS') + np.testing.assert_allclose(row['outputs'], [probe['outputs'][i] for i in output_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} outputs') + + def test_entire_registered_catalog_is_covered(self): + from app.simulation.native_codegen.contracts import SUPPORTED_VERSIONS + self.assertEqual(len(catalog_contracts()), 27) + self.assertEqual({key: cls.MODEL_VERSION for key, cls in catalog_contracts().items()}, SUPPORTED_VERSIONS) + + def test_four_rigidly_connected_masses_obey_newtons_law(self): + b = Circuit() + node = b.add('amesim_lmechn1', 'node', v1=3) + for i, name in enumerate((p.name for p in node.active_port_definitions), start=1): + mass = b.add('amesim_mecmas21', 'm'+str(i), mass=i, stoptype=4, useFriction=1) + force = b.add('amesim_forc', 'f'+str(i)) + signal = b.add('amesim_step0', 's'+str(i), initial=i*i, final=i*i, time=1) + b.connect(node, name, mass, 'port_1') + b.connect(mass, 'port_2', force, 'port_2') + b.connect(force, 'res', signal, 'out') + build = build_native(compile_extended_program(b.net)) + initial = json.loads(subprocess.run([str(build.executable), '--init'], capture_output=True, text=True, check=True).stdout) + self.assertEqual(len(initial), 2) + row = json.loads(subprocess.run([str(build.executable), '--probe'], input='0 '+' '.join(map(str, initial))+'\n', capture_output=True, text=True, check=True).stdout) + self.assertTrue(row['success']) + np.testing.assert_allclose(row['rhs'], [3, 0], atol=1e-12) + + def test_restitution_and_algebraic_models_execute_both_solvers(self): + import tempfile + from pathlib import Path + from app.simulation.config import SolveIVPConfig + from app.simulation.native_codegen.runner import execute_native + b=Circuit();m=b.add('amesim_mecmas21','mass',mass=1,stoptype=3,xmin=0,xmax=1,x0=.9,v0=1,restcoeff=.5,restdvel=.001,useFriction=1) + for name in ('port_1','port_2'): + free=b.add('amesim_f000','free_'+name);b.connect(m,name,free,'port_1') + build=build_native(compile_extended_program(b.net)) + signal=Circuit();signal.add('amesim_step0','step',time=.1,initial=2,final=4) + algebraic=build_native(compile_extended_program(signal.net)) + with tempfile.TemporaryDirectory() as tmp: + for method in ('RK45','BDF'): + cfg=SolveIVPConfig(method=method,t_stop=.4,max_step=.02,rtol=1e-7) + result=execute_native(build,cfg,.02,run_dir=Path(tmp)/method) + self.assertTrue(result['success']) + np.testing.assert_allclose(result['finalState'],[-.5,.85],atol=2e-6,rtol=1e-6) + result=execute_native(algebraic,cfg,.02,run_dir=Path(tmp)/(method+'-signal')) + self.assertTrue(result['success']) + self.assertEqual(result['series']['step.y'][-1],4) diff --git a/tests/test_native_codegen.py b/tests/test_native_codegen.py new file mode 100644 index 0000000..01c5a16 --- /dev/null +++ b/tests/test_native_codegen.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import replace +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import numpy as np + +from app.main import compile_system_xml_network, run_system_xml_simulation +from app.simulation.backends import simulation_config +from app.simulation.native_codegen.build import build_native, toolchain +from app.simulation.native_codegen.compiler import NativeCapabilityError, compile_native_program +from app.simulation.native_codegen.input import arithmetic_value, load_input, project_xml +from app.simulation.native_codegen.runner import execute_native +from app.simulation.config import SolverActivityTracker +from app.system_xml import validate_system_xml_document + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests/data/native-skill-test.xml" + + +def network(xml=None): + doc = validate_system_xml_document(xml or FIXTURE.read_bytes()).document + return compile_system_xml_network(doc) + + +class NativeInputTests(unittest.TestCase): + def test_arithmetic_is_bounded_and_does_not_execute_code(self): + self.assertEqual(arithmetic_value("3.14*10**2/4"), 78.5) + for bad in ("__import__('os')", "a.b", "[1][0]", "2**100000000", "1/0"): + with self.assertRaises((ValueError, ZeroDivisionError)): + arithmetic_value(bad) + + def test_ir_has_complete_state_and_output_mapping(self): + program = compile_native_program(network()) + self.assertEqual(len(program.state_keys), 12) + self.assertEqual(len(program.variables), 175) + self.assertNotIn("PyObject", program.source) + self.assertNotIn("amesim_mecmas21_5", program.source.split("int model_eval")[1]) + + def test_unknown_component_is_rejected_before_build(self): + n = network() + original = n.components["amesim_mecmas21_2"] + original.__class__ = type('CustomUnportedMass', (type(original),), {}) + with self.assertRaisesRegex(NativeCapabilityError, "no native contract"): + compile_native_program(n) + + +class NativeExecutionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + try: + toolchain() + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + raise unittest.SkipTest(f"Native toolchain unavailable: {exc}") + cls.temporary = tempfile.TemporaryDirectory(prefix="native-tests-") + cls.root = Path(cls.temporary.name) + cls.document = validate_system_xml_document(FIXTURE.read_bytes()).document + cls.program = compile_native_program(network()) + cls.build = build_native(cls.program) + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + + + + + def test_cache_and_standalone_executable_without_python_path(self): + cached = build_native(self.program) + self.assertTrue(cached.cache_hit) + env = {**os.environ, "PATH": str(Path(os.environ["SystemRoot"]) / "System32")} + r = subprocess.run([str(cached.executable), "--init"], env=env, cwd=self.root, + capture_output=True, text=True, check=True) + self.assertEqual(len(json.loads(r.stdout)), 12) + + def test_both_integrators_complete_and_respect_max_step(self): + for method in ("RK45", "BDF"): + config = replace(simulation_config(self.document.simulation), method=method, t_stop=.1) + data = execute_native(self.build, config, .02, run_dir=self.root / method) + self.assertTrue(data["success"]) + self.assertEqual(data["simulatedUntil"], .1) + self.assertLessEqual(data["maxAcceptedStep"], config.max_step+1e-14) + self.assertEqual(set(data["series"]), {"time", *(v.key for v in self.program.variables)}) + self.assertTrue(all(np.isfinite(v).all() for v in map(np.asarray, data["series"].values()))) + + def test_cancellation_returns_partial_accepted_state(self): + config = replace(simulation_config(self.document.simulation), max_step=1e-6) + tracker = SolverActivityTracker() + data = execute_native(self.build, config, .02, run_dir=self.root / "cancel", + cancel_check=lambda: True, activity_tracker=tracker) + self.assertEqual(data["status"], "cancelled") + self.assertLess(data["simulatedUntil"], config.t_stop) + self.assertEqual(data["series"]["time"][-1], data["simulatedUntil"]) + + def test_xml_api_defaults_to_native_and_preserves_result_contract(self): + xml = FIXTURE.read_bytes().replace(b'tStop="10"', b'tStop="0.1"') + with patch.dict(os.environ): + os.environ.pop("SIMULATION_NUMERIC_ENGINE", None) + data = run_system_xml_simulation(xml) + self.assertTrue(data["success"]) + self.assertEqual(data["diagnostics"]["backend"], "native-c") + self.assertEqual(data["diagnostics"]["stateCount"], 12) + self.assertEqual(data["diagnostics"]["sampleCount"], len(data["series"]["time"])) + self.assertEqual(len(data["variables"]), 175) + self.assertEqual(data["series"]["time"][-1], .1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_only_backend.py b/tests/test_native_only_backend.py new file mode 100644 index 0000000..e783d32 --- /dev/null +++ b/tests/test_native_only_backend.py @@ -0,0 +1,39 @@ +"""The retired numerical backend must not become a hidden runtime dependency.""" +import os +from pathlib import Path +import subprocess +import sys +import unittest +from unittest.mock import patch + +from app.simulation.backends import numeric_engine_name + + +class NativeOnlyBackendTests(unittest.TestCase): + def test_python_backend_is_rejected_explicitly(self): + with patch.dict(os.environ, {'SIMULATION_NUMERIC_ENGINE': 'python'}): + with self.assertRaisesRegex(ValueError, 'removed'): + numeric_engine_name() + self.assertEqual(numeric_engine_name('native-c'), 'native') + + def test_api_and_compiler_import_without_numpy_or_scipy(self): + code = ''' +import importlib.abc +import sys +from pathlib import Path +class NoNumericalPython(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in ('numpy', 'scipy'): + raise AssertionError('Retired numerical dependency imported: '+fullname) +sys.meta_path.insert(0, NoNumericalPython()) +from app.main import compile_system_xml_network +from app.simulation.native_codegen.compiler import compile_native_program +from app.system_xml import validate_system_xml_document +doc = validate_system_xml_document(Path('tests/data/native-skill-test.xml').read_bytes()).document +program = compile_native_program(compile_system_xml_network(doc)) +assert len(program.state_keys) == 12 +assert len(program.variables) == 175 +assert not any(name.startswith('app.simulation.solvers.') for name in sys.modules) +''' + subprocess.run([sys.executable, '-c', code], cwd=Path(__file__).resolve().parents[1], + capture_output=True, text=True, check=True, timeout=30) diff --git a/tests/test_peng_robinson.py b/tests/test_peng_robinson.py deleted file mode 100644 index 4312219..0000000 --- a/tests/test_peng_robinson.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.core.peng_robinson import AIR_PR, HELIUM_PR, NITROGEN_PR, PengRobinsonFluid - - -class PengRobinsonTest(unittest.TestCase): - def test_helium_near_ideal_at_atmospheric_condition(self) -> None: - z = HELIUM_PR.compressibility_factor(101_325.0, 300.0) - density = HELIUM_PR.density(101_325.0, 300.0) - - self.assertAlmostEqual(z, 1.0, delta=1.0e-3) - self.assertAlmostEqual(density, 0.1625, delta=0.002) - - def test_helium_density_pressure_round_trip(self) -> None: - pressure = 15.3e6 - temperature = 293.15 - density = HELIUM_PR.density(pressure, temperature) - - self.assertGreater(HELIUM_PR.compressibility_factor(pressure, temperature), 1.0) - self.assertAlmostEqual( - density, - 24.114225444477153, - delta=1.0e-12, - ) - self.assertAlmostEqual( - HELIUM_PR.pressure_from_density(temperature, density), - pressure, - delta=pressure * 1.0e-12, - ) - - def test_helium_residual_enthalpy_is_small_at_atmosphere(self) -> None: - self.assertAlmostEqual( - HELIUM_PR.residual_specific_enthalpy(101_300.0, 298.15), - 25.3556466754, - delta=0.01, - ) - - def test_helium_residual_enthalpy_captures_high_pressure_departure(self) -> None: - self.assertAlmostEqual( - HELIUM_PR.residual_specific_enthalpy(13_839_965.0, 287.7322), - 12788.0457342, - delta=0.1, - ) - - def test_residual_isochoric_heat_capacity_is_internal_energy_derivative( - self, - ) -> None: - temperature = 292.3997890954483 - density = 24.02697515640726 - temperature_step = 1.0e-3 - numerical_derivative = ( - HELIUM_PR.residual_specific_internal_energy_at_density( - temperature + temperature_step, - density, - ) - - HELIUM_PR.residual_specific_internal_energy_at_density( - temperature - temperature_step, - density, - ) - ) / (2.0 * temperature_step) - - self.assertAlmostEqual( - HELIUM_PR.residual_isochoric_heat_capacity_at_density( - temperature, - density, - ), - numerical_derivative, - delta=1.0e-6, - ) - - def test_air_reference_remains_available_for_other_models(self) -> None: - z = AIR_PR.compressibility_factor(101_325.0, 300.0) - density = AIR_PR.density(101_325.0, 300.0) - - self.assertAlmostEqual(z, 1.0, delta=1.0e-3) - self.assertAlmostEqual(density, 1.177, delta=0.01) - - def test_nitrogen_can_return_multiple_roots(self) -> None: - roots = NITROGEN_PR.compressibility_roots(1.0e6, 100.0) - - self.assertEqual(len(roots), 3) - self.assertLess(roots[0], roots[-1]) - self.assertEqual(NITROGEN_PR.compressibility_factor(1.0e6, 100.0, phase="liquid"), roots[0]) - self.assertEqual(NITROGEN_PR.compressibility_factor(1.0e6, 100.0, phase="vapor"), roots[-1]) - - def test_custom_fluid_can_be_defined_explicitly(self) -> None: - fluid = PengRobinsonFluid( - name="custom_helium", - molar_mass=0.004002602, - critical_temperature=5.1953, - critical_pressure=227_460.0, - acentric_factor=-0.382, - ) - - self.assertAlmostEqual(fluid.specific_gas_constant, 2077.3, delta=0.5) - self.assertAlmostEqual(fluid.compressibility_factor(1.0e6, 298.15), HELIUM_PR.compressibility_factor(1.0e6, 298.15)) - - def test_rejects_invalid_states(self) -> None: - with self.assertRaises(ValueError): - HELIUM_PR.compressibility_factor(0.0, 300.0) - with self.assertRaises(ValueError): - HELIUM_PR.density(101_325.0, 0.0) - with self.assertRaises(ValueError): - HELIUM_PR.pressure_from_density(300.0, -1.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_performance_benchmark.py b/tests/test_performance_benchmark.py deleted file mode 100644 index 3796946..0000000 --- a/tests/test_performance_benchmark.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.benchmark_performance import ( - _duration_summary, - _load_factory_xml, - _named_value, - _parse_arguments, - _serialize_result_event, -) - - -def sample_xml_factory() -> bytes: - return b"" - - -class PerformanceBenchmarkToolTests(unittest.TestCase): - def test_named_value_requires_an_explicit_name(self) -> None: - self.assertEqual( - _named_value("chain=tests.example:project", option="--factory"), - ("chain", "tests.example:project"), - ) - with self.assertRaisesRegex(ValueError, "NAME=VALUE"): - _named_value("tests.example:project", option="--factory") - - def test_duration_summary_reports_repeatable_order_statistics(self) -> None: - summary = _duration_summary([5.0, 1.0, 3.0, 2.0, 4.0]) - - self.assertEqual(summary["minimumMs"], 1.0) - self.assertEqual(summary["medianMs"], 3.0) - self.assertEqual(summary["p95Ms"], 5.0) - self.assertEqual(summary["maximumMs"], 5.0) - - def test_factory_loader_accepts_a_bytes_factory(self) -> None: - self.assertEqual( - _load_factory_xml( - "tests.test_performance_benchmark:sample_xml_factory" - ), - b"", - ) - - def test_result_event_serialization_uses_ndjson_shape(self) -> None: - payload = _serialize_result_event( - { - "success": True, - "status": "completed", - "simulatedUntil": 1.0, - "requestedStopTime": 1.0, - } - ) - - self.assertTrue(payload.endswith(b"\n")) - self.assertIn(b'"event":"result"', payload) - self.assertIn(b'"result":{"success":true', payload) - - def test_cache_ab_flag_is_explicit(self) -> None: - arguments = _parse_arguments( - [ - "--factory", - "sample=tests.test_performance_benchmark:sample_xml_factory", - "--disable-property-cache", - ] - ) - - self.assertTrue(arguments.disable_property_cache) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_physical_state_v21.py b/tests/test_physical_state_v21.py deleted file mode 100644 index e844083..0000000 --- a/tests/test_physical_state_v21.py +++ /dev/null @@ -1,487 +0,0 @@ -from __future__ import annotations - -import copy -import hashlib -import json -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - -from app.simulation.benchmark_regression import summarize_simulation_result -from app.simulation.physical_state_v21 import ( - PHYSICAL_STATE_V21_ID, - PhysicalStateV21Error, - approve_candidate_golden, - assess_report_against_amesim, - build_amesim_reference, - build_candidate_golden, - compare_contract_to_amesim, - evaluate_physical_state_v21, - load_approved_golden, - project_physical_state_v21, - write_candidate_golden, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -AMESIM_ARCHIVE = REPO_ROOT / "AmesimModels" / "test_mql.ame" -CANDIDATE_REPORT = ( - REPO_ROOT - / "tests/baselines/simulation/test_mql_8/runs" - / "2026-08-18-production-amesim-aligned-v1-candidate-0.2.json" -) - - -def _variable(key: str, quantity: str, unit: str, category: str) -> dict[str, str]: - return {"key": key, "quantity": quantity, "unit": unit, "category": category} - - -def _synthetic_result() -> dict[str, object]: - times = [0.0, 0.04, 0.2] - variables: list[dict[str, str]] = [] - series: dict[str, list[float]] = {"time": times} - - def add( - key: str, - values: list[float], - quantity: str, - unit: str, - category: str, - ) -> None: - variables.append(_variable(key, quantity, unit, category)) - series[key] = values - - add("amesim_pnch012_8.p", [100_000.0, 200_000.0, 300_000.0], "pressure", "Pa", "thermodynamic") - add("amesim_pnl0001_20.p", [100_000.0, 210_000.0, 310_000.0], "pressure", "Pa", "thermodynamic") - add("amesim_pnl0001_20.port_1.m_flow", [-0.1, -0.2, -0.3], "mass_flow", "kg/s", "port") - add("amesim_pnch012_8.port_1.m_flow", [0.1, 0.2, 0.3], "mass_flow", "kg/s", "port") - add("amesim_pnvo001_5.port_2.m_flow", [-0.2, -0.3, -0.4], "mass_flow", "kg/s", "port") - add("amesim_pnvo001_5.port_3.m_flow", [0.2, 0.3, 0.4], "mass_flow", "kg/s", "port") - for port, values in enumerate( - ([0.4, 0.5, 0.6], [-0.1, -0.2, -0.3], [-0.2, -0.2, -0.2], [-0.1, -0.1, -0.1]), - start=1, - ): - add(f"amesim_p4node2_8.port_{port}.m_flow", list(values), "mass_flow", "kg/s", "port") - add("amesim_pnvo001_5.xv", [0.0, 1.0, 1.0], "dimensionless", "", "derived") - add("amesim_mecmas21_9.x", [0.0, 0.0, 0.0], "length", "m", "state") - add("amesim_mecmas21_9.v", [0.0, 0.0, 0.0], "velocity", "m/s", "state") - add("amesim_mecmas21_9.port_1.f", [1.0, 1.0, 1.0], "force", "N", "port") - add("amesim_mecmas21_9.port_2.f", [2.0, 2.0, 2.0], "force", "N", "port") - add("amesim_mecmas21_10.x", [0.0, 0.0, 0.0], "length", "m", "state") - add("amesim_mecmas21_10.v", [0.0, 0.0, 0.0], "velocity", "m/s", "state") - add("amesim_mecmas21_10.port_1.f", [-2.0, -2.0, -2.0], "force", "N", "port") - add("amesim_mecmas21_10.port_2.f", [-1.0, -1.0, -1.0], "force", "N", "port") - add("storage_a.m", [1.0, 2.0, 3.0], "mass", "kg", "state") - add("storage_b.m", [4.0, 3.0, 2.0], "mass", "kg", "state") - return { - "success": True, - "status": "completed", - "partial": False, - "simulatedUntil": 0.2, - "requestedStopTime": 0.2, - "variables": variables, - "series": series, - "final": {key: values[-1] for key, values in series.items() if key != "time"}, - "diagnostics": {}, - } - - -class PhysicalStateProjectionTests(unittest.TestCase): - def test_projects_all_categories_with_units_and_strict_balances(self) -> None: - contract = project_physical_state_v21( - _synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04 - ) - - self.assertEqual(contract["id"], PHYSICAL_STATE_V21_ID) - self.assertEqual( - contract["projectionCategories"], - ["pressure", "massFlow", "conservation", "discreteMode"], - ) - self.assertEqual(len(contract["layout"]["projectionKeys"]), 11) - endpoint = contract["checkpoints"][-1]["values"] - self.assertEqual(endpoint["conservation.totalStoredGasMass"], 5.0) - self.assertAlmostEqual(endpoint["conservation.p4node2_8.massBalance"], 0.0) - self.assertAlmostEqual(endpoint["conservation.pnvo001_5.massBalance"], 0.0) - self.assertAlmostEqual( - endpoint["conservation.pnl0001_20_pnch012_8.connectionMassBalance"], 0.0 - ) - self.assertEqual(endpoint["discrete.pnvo001_5.openMode"], 1.0) - self.assertEqual(endpoint["discrete.mecmas21_9.endstopMode"], 1.0) - self.assertEqual(endpoint["discrete.mecmas21_10.endstopMode"], -1.0) - pressure = contract["layout"]["projections"][0]["amesim"] - self.assertEqual(pressure["offset"], 101_300.0) - flow = contract["layout"]["projections"][2]["amesim"] - self.assertEqual(flow["scale"], -1.0e-3) - - def test_fails_closed_on_wrong_source_unit(self) -> None: - result = _synthetic_result() - next( - variable - for variable in result["variables"] - if variable["key"] == "amesim_pnch012_8.p" - )["unit"] = "bar" - - with self.assertRaisesRegex(PhysicalStateV21Error, "Unexpected metadata"): - project_physical_state_v21(result, checkpoint_times=(0.2,), sample_step=0.2) - - def test_benchmark_summary_attaches_v21_only_to_matching_result(self) -> None: - summary = summarize_simulation_result( - _synthetic_result(), checkpoint_times=(0.0, 0.2), sample_step=0.2 - ) - self.assertEqual(summary["physicalStateV21"]["id"], PHYSICAL_STATE_V21_ID) - - -class AmesimReferenceTests(unittest.TestCase): - def test_reference_records_archive_hash_units_signs_and_known_endpoint(self) -> None: - reference = build_amesim_reference(AMESIM_ARCHIVE, checkpoint_times=(0.2,)) - values = reference["checkpoints"][0]["values"] - - self.assertAlmostEqual(values["pressure.pnch012_8.absolute"], 3_625_053.2009413484) - self.assertAlmostEqual( - values["massFlow.pnl0001_20.port_1.intoComponent"], - -0.3728806429214546, - ) - self.assertEqual(values["discrete.mecmas21_9.endstopMode"], 1.0) - self.assertEqual(values["discrete.mecmas21_10.endstopMode"], -1.0) - provenance = reference["provenance"] - self.assertEqual(provenance["archivePath"], "AmesimModels/test_mql.ame") - self.assertEqual(len(provenance["archiveSha256"]), 64) - self.assertEqual( - [member["name"] for member in provenance["members"]], - ["test_mql_.var", "test_mql_.results"], - ) - - def test_current_candidate_endpoint_passes_explicit_amesim_alignment_profile(self) -> None: - assessment = assess_report_against_amesim( - CANDIDATE_REPORT, archive_path=AMESIM_ARCHIVE - ) - - comparison = assessment["comparison"] - self.assertEqual(assessment["coverage"], "legacyEndpointOnly") - self.assertTrue(comparison["passed"]) - self.assertEqual(comparison["comparedValueCount"], 9) - self.assertEqual(comparison["metricCount"], 11) - self.assertEqual(comparison["availableBaselineValueCount"], 9) - self.assertEqual(comparison["unavailableBaselineValueCount"], 2) - self.assertEqual(len(comparison["metrics"]), 11) - self.assertTrue( - all( - { - "requestedTime", - "key", - "category", - "actual", - "amesimBaseline", - "absoluteError", - "relativeError", - "evaluated", - "passed", - } - <= set(metric) - for metric in comparison["metrics"] - ) - ) - self.assertEqual( - comparison["worstValue"]["key"], - "massFlow.pnl0001_20.port_1.intoComponent", - ) - self.assertAlmostEqual( - comparison["worstValue"]["relativeError"], - 0.0013934852135053246, - ) - self.assertEqual( - comparison["toleranceProfile"]["byCategory"]["discreteMode"]["relative"], - 0.0, - ) - - def test_signal_jump_mass_flows_record_errors_but_are_not_evaluated(self) -> None: - contract = project_physical_state_v21( - _synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04 - ) - reference_checkpoints = [] - for checkpoint in contract["checkpoints"]: - values = dict(checkpoint["values"]) - values["conservation.p4node2_8.massBalance"] = None - values[ - "conservation.pnl0001_20_pnch012_8.connectionMassBalance" - ] = None - if checkpoint["requestedTime"] == 0.04: - values["massFlow.pnl0001_20.port_1.intoComponent"] += 100.0 - values["massFlow.pnvo001_5.port_2.intoComponent"] -= 100.0 - reference_checkpoints.append( - {"requestedTime": checkpoint["requestedTime"], "values": values} - ) - - comparison = compare_contract_to_amesim( - contract, {"checkpoints": reference_checkpoints} - ) - - excluded = [ - metric - for metric in comparison["metrics"] - if metric["requestedTime"] == 0.04 and metric["category"] == "massFlow" - ] - self.assertTrue(comparison["passed"]) - self.assertEqual(comparison["metricCount"], 33) - self.assertEqual(comparison["availableBaselineValueCount"], 27) - self.assertEqual(comparison["unavailableBaselineValueCount"], 6) - self.assertEqual(comparison["comparedValueCount"], 25) - self.assertEqual(comparison["excludedValueCount"], 2) - self.assertEqual(len(comparison["metrics"]), 33) - self.assertEqual(len(excluded), 2) - self.assertTrue(all(metric["evaluated"] is False for metric in excluded)) - self.assertTrue(all(metric["passed"] is None for metric in excluded)) - self.assertTrue(all(metric["absoluteError"] > 99.0 for metric in excluded)) - self.assertTrue( - all("LeftRightLimit" in metric["exclusionReason"] for metric in excluded) - ) - self.assertNotIn(comparison["worstValue"], excluded) - - unavailable = [ - metric - for metric in comparison["metrics"] - if metric["amesimBaseline"] is None - ] - self.assertEqual(len(unavailable), 6) - self.assertTrue(all(metric["evaluated"] is False for metric in unavailable)) - self.assertTrue(all(metric["absoluteError"] is None for metric in unavailable)) - self.assertTrue(all(metric["relativeError"] is None for metric in unavailable)) - - def test_relative_error_uses_the_amesim_baseline_denominator(self) -> None: - contract = project_physical_state_v21( - _synthetic_result(), checkpoint_times=(0.2,), sample_step=0.2 - ) - values = dict(contract["checkpoints"][0]["values"]) - key = "pressure.pnch012_8.absolute" - values[key] = 0.5 * values[key] - - comparison = compare_contract_to_amesim( - contract, - {"checkpoints": [{"requestedTime": 0.2, "values": values}]}, - ) - metric = next(item for item in comparison["metrics"] if item["key"] == key) - expected = abs(metric["actual"] - metric["amesimBaseline"]) / abs( - metric["amesimBaseline"] - ) - - self.assertAlmostEqual(metric["relativeError"], expected) - self.assertAlmostEqual(metric["relativeErrorPercent"], 100.0 * expected) - self.assertTrue(metric["relativeErrorDefined"]) - - def test_zero_amesim_baseline_has_undefined_relative_error(self) -> None: - contract = project_physical_state_v21( - _synthetic_result(), checkpoint_times=(0.2,), sample_step=0.2 - ) - values = dict(contract["checkpoints"][0]["values"]) - key = "massFlow.pnl0001_20.port_1.intoComponent" - values[key] = -0.0 - - comparison = compare_contract_to_amesim( - contract, - {"checkpoints": [{"requestedTime": 0.2, "values": values}]}, - ) - metric = next(item for item in comparison["metrics"] if item["key"] == key) - - self.assertIsNone(metric["relativeError"]) - self.assertIsNone(metric["relativeErrorPercent"]) - self.assertFalse(metric["relativeErrorDefined"]) - self.assertEqual(metric["relativeErrorReason"], "zeroAmesimBaseline") - self.assertEqual(metric["comparisonBasis"], "absoluteNearZero") - self.assertEqual( - json.loads(json.dumps(comparison, allow_nan=False))["baselineAuthority"], - "amesim", - ) - - -class ReviewedGoldenProtocolTests(unittest.TestCase): - def _report_and_reference(self): - contract = project_physical_state_v21( - _synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04 - ) - report = { - "generatedAt": "2026-08-18T00:00:00+00:00", - "source": {"sha256": "a" * 64}, - "cases": [{ - "caseId": "0.2s", - "lane": "production", - "worker": {"summary": {"physicalStateV21": contract}}, - }], - } - keys = contract["layout"]["projectionKeys"] - reference_checkpoints = [] - for checkpoint in contract["checkpoints"]: - reference_checkpoints.append({ - "requestedTime": checkpoint["requestedTime"], - "values": {key: checkpoint["values"][key] for key in keys}, - }) - reference = { - "schemaVersion": 1, - "provenance": { - "archivePath": "AmesimModels/synthetic.ame", - "archiveBytes": 2, - "archiveSha256": "b" * 64, - "members": [ - {"name": "synthetic.var", "bytes": 1, "sha256": "c" * 64}, - { - "name": "synthetic.results", - "bytes": 1, - "sha256": "d" * 64, - }, - ], - }, - "storedMassDataPaths": ["mgas@synthetic"], - "checkpoints": reference_checkpoints, - } - return contract, report, reference - - def test_candidate_requires_exact_hash_and_is_approved_to_a_new_file(self) -> None: - contract, report, reference = self._report_and_reference() - with tempfile.TemporaryDirectory( - dir=REPO_ROOT, prefix=".physical-state-v21-test-" - ) as temporary_directory: - root = Path(temporary_directory) - report_path = root / "report.json" - report_path.write_text(json.dumps(report), encoding="utf-8") - candidate_path = root / "candidate.json" - approved_path = root / "approved.json" - with patch( - "app.simulation.physical_state_v21.build_amesim_reference", - return_value=reference, - ): - write_candidate_golden(report_path, candidate_path) - candidate = json.loads(candidate_path.read_text(encoding="utf-8")) - local_report = candidate["provenance"]["localSourceReport"] - self.assertFalse(Path(local_report["path"]).is_absolute()) - self.assertEqual(local_report["bytes"], report_path.stat().st_size) - candidate_sha = hashlib.sha256(candidate_path.read_bytes()).hexdigest() - - with self.assertRaisesRegex(PhysicalStateV21Error, "SHA mismatch"): - approve_candidate_golden( - candidate_path, - approved_path, - expected_candidate_sha256="0" * 64, - reviewed_by="reviewer", - note="reviewed", - ) - approve_candidate_golden( - candidate_path, - approved_path, - expected_candidate_sha256=candidate_sha, - reviewed_by="reviewer", - note="units, signs, and errors reviewed", - ) - golden = load_approved_golden(approved_path) - self.assertFalse(Path(golden["approval"]["candidatePath"]).is_absolute()) - golden["checkpoints"][-1]["values"][0] += 1.0e9 - self.assertTrue(evaluate_physical_state_v21(contract, golden)["passed"]) - - changed = copy.deepcopy(contract) - changed["checkpoints"][-1]["values"]["pressure.pnch012_8.absolute"] += 1000.0 - failure = evaluate_physical_state_v21(changed, golden) - self.assertFalse(failure["passed"]) - self.assertIn("amesimReferenceToleranceMismatch", failure["issues"]) - - def test_approval_rejects_failed_or_tampered_alignment(self) -> None: - _contract, report, reference = self._report_and_reference() - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - report_path = root / "report.json" - report_path.write_text(json.dumps(report), encoding="utf-8") - original_path = root / "candidate.json" - with patch( - "app.simulation.physical_state_v21.build_amesim_reference", - return_value=reference, - ): - write_candidate_golden(report_path, original_path) - original = json.loads(original_path.read_text(encoding="utf-8")) - - failed_alignment = copy.deepcopy(original) - failed_alignment["amesimAlignmentAtGeneration"]["passed"] = False - failed_path = root / "failed-alignment.json" - failed_path.write_text(json.dumps(failed_alignment), encoding="utf-8") - with self.assertRaisesRegex( - PhysicalStateV21Error, "AMESim alignment at generation" - ): - approve_candidate_golden( - failed_path, - root / "failed-approved.json", - expected_candidate_sha256=hashlib.sha256( - failed_path.read_bytes() - ).hexdigest(), - reviewed_by="reviewer", - note="must not override a failed alignment", - ) - - failed_metric = copy.deepcopy(original) - evaluated_metric = next( - metric - for metric in failed_metric["amesimAlignmentAtGeneration"]["metrics"] - if metric["evaluated"] - ) - evaluated_metric["passed"] = False - failed_metric_path = root / "failed-metric.json" - failed_metric_path.write_text(json.dumps(failed_metric), encoding="utf-8") - with self.assertRaisesRegex(PhysicalStateV21Error, "evaluated metric"): - approve_candidate_golden( - failed_metric_path, - root / "failed-metric-approved.json", - expected_candidate_sha256=hashlib.sha256( - failed_metric_path.read_bytes() - ).hexdigest(), - reviewed_by="reviewer", - note="must not override a failed metric", - ) - - def test_approval_rejects_local_conservation_residual(self) -> None: - _contract, report, reference = self._report_and_reference() - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - report_path = root / "report.json" - report_path.write_text(json.dumps(report), encoding="utf-8") - original_path = root / "candidate.json" - with patch( - "app.simulation.physical_state_v21.build_amesim_reference", - return_value=reference, - ): - write_candidate_golden(report_path, original_path) - candidate = json.loads(original_path.read_text(encoding="utf-8")) - key = "conservation.p4node2_8.massBalance" - value_index = candidate["layout"]["projectionKeys"].index(key) - candidate["checkpoints"][1]["values"][value_index] = 2.0e-9 - tampered_path = root / "conservation-residual.json" - tampered_path.write_text(json.dumps(candidate), encoding="utf-8") - - with self.assertRaisesRegex( - PhysicalStateV21Error, "local conservation residual" - ): - approve_candidate_golden( - tampered_path, - root / "residual-approved.json", - expected_candidate_sha256=hashlib.sha256( - tampered_path.read_bytes() - ).hexdigest(), - reviewed_by="reviewer", - note="must not override a local conservation failure", - ) - - def test_candidate_writer_refuses_overwrite_and_unapproved_file_is_rejected(self) -> None: - _contract, report, reference = self._report_and_reference() - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - report_path = root / "report.json" - report_path.write_text(json.dumps(report), encoding="utf-8") - output = root / "candidate.json" - with patch( - "app.simulation.physical_state_v21.build_amesim_reference", - return_value=reference, - ): - write_candidate_golden(report_path, output) - with self.assertRaisesRegex(PhysicalStateV21Error, "overwrite"): - write_candidate_golden(report_path, output) - with self.assertRaisesRegex(PhysicalStateV21Error, "approval.status"): - load_approved_golden(output) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_pnl0002_replay.py b/tests/test_pnl0002_replay.py deleted file mode 100644 index 4995cda..0000000 --- a/tests/test_pnl0002_replay.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.components.amesim.flow.pipes import AmesimPnl0002 -from app.simulation.core.medium import IdealGasMedium -from app.simulation.reporting.amesim_results import AmesimResults -from app.simulation.reporting.pnl0002_replay import ( - Pnl0002ReplayPaths, - replay_pnl0002_amesim_states, -) - - -class Pnl0002ReplayTests(unittest.TestCase): - def setUp(self) -> None: - self.pipe = AmesimPnl0002( - "pneumatic_83", - IdealGasMedium(), - diam=0.02, - le=2.0, - rr=0.00225, - ) - self.paths = Pnl0002ReplayPaths( - center_pressure="center_p", - center_temperature="center_T", - port_1_pressure="port_1_p", - port_1_temperature="port_1_T", - port_1_mass_flow="port_1_dm", - port_2_pressure="port_2_p", - port_2_temperature="port_2_T", - port_2_mass_flow="port_2_dm", - reynolds="re", - friction_factor="ff", - ) - - def _matching_results(self) -> AmesimResults: - center_pressure = 300000.0 - center_temperature = 320.0 - port_1_pressure = 500000.0 - port_1_temperature = 330.0 - port_2_pressure = 100000.0 - port_2_temperature = 310.0 - flow_1 = self.pipe.mass_flow( - port_1_pressure, - center_pressure, - port_1_temperature, - ) - flow_2 = self.pipe.mass_flow( - port_2_pressure, - center_pressure, - center_temperature, - ) - reynolds_1 = self.pipe.reynolds_number(flow_1, port_1_temperature) - reynolds_2 = self.pipe.reynolds_number(flow_2, center_temperature) - friction = 0.5 * ( - self.pipe.friction_factor(reynolds_1) - + self.pipe.friction_factor(reynolds_2) - ) - return AmesimResults( - times=(0.0,), - variables=(), - saved_variable_indices=(), - series_by_data_path={ - "center_p": (center_pressure,), - "center_T": (center_temperature,), - "port_1_p": (port_1_pressure,), - "port_1_T": (port_1_temperature,), - "port_1_dm": (flow_1 / -1.0e-3,), - "port_2_p": (port_2_pressure,), - "port_2_T": (port_2_temperature,), - "port_2_dm": (flow_2 / -1.0e-3,), - "re": (0.5 * (reynolds_1 + reynolds_2),), - "ff": (friction,), - }, - final_values_by_data_path={}, - ) - - def test_matching_saved_states_replay_without_integration(self) -> None: - initial_state = self.pipe.get_state_vector() - - report = replay_pnl0002_amesim_states( - self.pipe, - self._matching_results(), - self.paths, - ) - - self.assertEqual(report["mode"], "amesim-state-replay-no-integration") - self.assertEqual(report["pointCount"], 1) - self.assertEqual(self.pipe.get_state_vector(), initial_state) - row = report["rows"][0] - self.assertAlmostEqual(row["port1MassFlowError"], 0.0, places=15) - self.assertAlmostEqual(row["port2MassFlowError"], 0.0, places=15) - self.assertAlmostEqual(row["reynoldsError"], 0.0, places=12) - self.assertAlmostEqual(row["frictionFactorError"], 0.0, places=15) - - def test_missing_saved_variable_has_actionable_error(self) -> None: - results = self._matching_results() - results.series_by_data_path.pop("ff") - - with self.assertRaisesRegex( - ValueError, - "AMESim replay variable is not saved: ff", - ): - replay_pnl0002_amesim_states(self.pipe, results, self.paths) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_pressure_flow_causal_execution.py b/tests/test_pressure_flow_causal_execution.py deleted file mode 100644 index 97b25a3..0000000 --- a/tests/test_pressure_flow_causal_execution.py +++ /dev/null @@ -1,890 +0,0 @@ -from __future__ import annotations - -import os -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: - environment[CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE] = ( - "1" if executor_v2 else "0" - ) - if coordinate_kernel is not None: - environment[CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE] = ( - "1" if coordinate_kernel else "0" - ) - 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, - ): - if executor_v2 is None: - os.environ.pop(CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, None) - if coordinate_kernel is None: - os.environ.pop( - CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE, - None, - ) - 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) - enabled = _system(zero_force_mass_project(), executor_v2=True) - - self.assertTrue( - default.pressure_flow_solver.causal_execution_diagnostics()[ - "executorV2Configured" - ] - ) - self.assertFalse( - disabled.pressure_flow_solver.causal_execution_diagnostics()[ - "executorV2Configured" - ] - ) - self.assertTrue( - enabled.pressure_flow_solver.causal_execution_diagnostics()[ - "executorV2Configured" - ] - ) - - def test_coordinate_kernel_is_default_on_and_independently_disabled( - self, - ) -> None: - default = _system( - zero_force_mass_project(), - executor_v2=True, - ) - disabled = _system( - zero_force_mass_project(), - executor_v2=True, - coordinate_kernel=False, - ) - - default_diagnostics = ( - default.pressure_flow_solver.causal_execution_diagnostics() - ) - disabled_diagnostics = ( - disabled.pressure_flow_solver.causal_execution_diagnostics() - ) - self.assertTrue(default_diagnostics["coordinateKernelConfigured"]) - self.assertTrue(default_diagnostics["coordinateKernelEnabled"]) - self.assertFalse(disabled_diagnostics["coordinateKernelConfigured"]) - self.assertFalse(disabled_diagnostics["coordinateKernelEnabled"]) - - def test_compiled_v2_matches_v1_bitwise_without_target_names(self) -> None: - compiled = _system( - high_pressure_helium_step_project(), - executor_v2=True, - ) - v1 = _system(high_pressure_helium_step_project()) - compiled_state = compiled.initial_state_vector() - v1_state = v1.initial_state_vector() - - for time in (0.0, 0.041, 0.8): - self.assertEqual( - compiled.rhs(time, compiled_state), - v1.rhs(time, v1_state), - ) - self.assertEqual( - tuple( - unknown.read() - for unknown in compiled.pressure_flow_solver.unknowns - ), - tuple( - unknown.read() - for unknown in v1.pressure_flow_solver.unknowns - ), - ) - - diagnostics = ( - compiled.pressure_flow_solver.causal_execution_diagnostics() - ) - self.assertGreater(diagnostics["executorV2FastSolveCount"], 0) - self.assertGreater(diagnostics["coordinateKernelFastSolveCount"], 0) - self.assertEqual( - diagnostics["compiledAssignmentCount"], - len(compiled.pressure_flow_solver.unknowns), - ) - self.assertEqual( - diagnostics["executorV2RuntimeValidationFailureCount"], - 0, - ) - self.assertEqual( - diagnostics["canonicalCoordinateCount"], - diagnostics["logicalEffortCoordinateCount"] - + diagnostics["compiledFlowAssignmentCount"], - ) - self.assertEqual( - diagnostics["eliminatedEffortAliasCount"], - diagnostics["compiledEffortUnknownCount"] - - diagnostics["logicalEffortCoordinateCount"], - ) - - def test_compiled_v2_fast_solve_skips_legacy_seed_scan_and_scales( - self, - ) -> None: - system = _system(zero_force_mass_project(), executor_v2=True) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - - with patch.object( - solver, - "_solve_explicit_flow_unknowns", - wraps=solver._solve_explicit_flow_unknowns, - ) as legacy_seed, patch.object( - solver, - "_pressure_flow_equation_values", - wraps=solver._pressure_flow_equation_values, - ) as residuals, patch.object( - solver, - "_scales", - wraps=solver._scales, - ) as scales, patch.object( - solver, - "_evaluate_explicit_flow_stage", - wraps=solver._evaluate_explicit_flow_stage, - ) as allocating_stage: - diagnostics = solver.solve(effort_variables=("p",)) - - legacy_seed.assert_not_called() - residuals.assert_not_called() - scales.assert_not_called() - allocating_stage.assert_not_called() - self.assertIs( - diagnostics, - solver._causal_cached_fast_diagnostics, - ) - self.assertTrue(diagnostics.causal_fast_path_used) - - def test_compiled_v2_nonfinite_assignment_fuses_and_audits_same_solve( - self, - ) -> None: - system = _system(zero_force_mass_project(), executor_v2=True) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - with patch.object( - solver, - "_execute_causal_coordinate_flow_plan", - return_value="nonFiniteCausalFlowAssignment", - ): - diagnostics = solver.solve(effort_variables=("p",)) - - self.assertTrue(diagnostics.success) - self.assertTrue(diagnostics.residual_verified_this_solve) - execution = solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "nonFiniteCausalFlowAssignment", - ) - self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_compiled_v2_rejects_nonfinite_external_mechanical_effort( - self, - ) -> None: - system = _system(zero_force_mass_project(), executor_v2=True) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - velocity = next( - unknown for unknown in solver.unknowns if unknown.variable == "v" - ) - velocity.write(float("nan")) - - with self.assertRaises(ValueError): - solver.solve(effort_variables=("p",)) - - execution = solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "nonFiniteCausalExternalEffort", - ) - self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_compiled_v2_stage_error_fuses_to_existing_fallback(self) -> None: - system = _system(zero_force_mass_project(), executor_v2=True) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - with patch.object( - solver, - "_execute_causal_coordinate_flow_plan", - return_value="causalFlowEvaluationFailed:ValueError", - ): - diagnostics = solver.solve(effort_variables=("p",)) - - self.assertTrue(diagnostics.success) - self.assertTrue(diagnostics.residual_verified_this_solve) - execution = solver.causal_execution_diagnostics() - self.assertEqual( - execution["disabledReason"], - "causalFlowEvaluationFailed:ValueError", - ) - self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1) - - def test_compiled_v2_assignment_count_drift_fuses_to_fallback(self) -> None: - system = _system(zero_force_mass_project(), executor_v2=True) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - with patch.object( - solver, - "_execute_causal_coordinate_flow_plan", - return_value="causalFlowAssignmentCountMismatch", - ): - diagnostics = solver.solve(effort_variables=("p",)) - - self.assertTrue(diagnostics.success) - self.assertTrue(diagnostics.residual_verified_this_solve) - execution = solver.causal_execution_diagnostics() - self.assertEqual( - execution["disabledReason"], - "causalFlowAssignmentCountMismatch", - ) - self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1) - - def test_compiled_v2_keeps_the_sixty_four_solve_audit_boundary(self) -> None: - system = _system(zero_force_mass_project(), executor_v2=True) - state = system.initial_state_vector() - solver = system.pressure_flow_solver - system.rhs(0.0, state) - - for _iteration in range(64): - system.rhs(0.0, state) - before = solver.causal_execution_diagnostics() - self.assertEqual(before["executorV2FastSolveCount"], 64) - self.assertEqual(before["fullResidualAuditCount"], 1) - - system.rhs(0.0, state) - after = solver.causal_execution_diagnostics() - self.assertEqual(after["executorV2FastSolveCount"], 64) - self.assertEqual(after["fullResidualAuditCount"], 2) - - def test_strict_causal_rhs_matches_environment_disabled_legacy_bitwise( - self, - ) -> None: - optimized = _system(high_pressure_helium_step_project()) - with patch.dict( - os.environ, - {CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE: "0"}, - ): - legacy = _system(high_pressure_helium_step_project()) - - optimized_state = optimized.initial_state_vector() - legacy_state = legacy.initial_state_vector() - for time in (0.0, 0.041, 0.8): - optimized_derivative = optimized.rhs(time, optimized_state) - legacy_derivative = legacy.rhs(time, legacy_state) - self.assertEqual(optimized_derivative, legacy_derivative) - self.assertEqual( - tuple( - unknown.read() - for unknown in optimized.pressure_flow_solver.unknowns - ), - tuple( - unknown.read() - for unknown in legacy.pressure_flow_solver.unknowns - ), - ) - - global_diagnostics = ( - optimized.pressure_flow_solver.causal_execution_diagnostics() - ) - self.assertTrue(global_diagnostics["eligible"]) - self.assertGreater(global_diagnostics["fastSolveCount"], 0) - self.assertGreaterEqual( - global_diagnostics["fullResidualAuditCount"], - 1, - ) - secondary = ( - optimized._thermofluid_closure_plan.secondary_block_solvers[0] - ) - secondary_diagnostics = secondary.causal_execution_diagnostics() - self.assertTrue(secondary_diagnostics["eligible"]) - self.assertGreater(secondary_diagnostics["fastSolveCount"], 0) - - legacy_diagnostics = ( - legacy.pressure_flow_solver.causal_execution_diagnostics() - ) - self.assertFalse(legacy_diagnostics["enabled"]) - self.assertEqual( - legacy_diagnostics["disabledReason"], - "disabledByEnvironment", - ) - self.assertEqual(legacy_diagnostics["fastSolveCount"], 0) - - def test_multiple_effort_anchors_conservatively_keep_legacy_path(self) -> None: - system = _system(chain_project()) - diagnostics = ( - system.pressure_flow_solver.causal_execution_diagnostics() - ) - - self.assertFalse(diagnostics["eligible"]) - self.assertFalse(diagnostics["enabled"]) - self.assertEqual( - diagnostics["fallbackReason"], - "effortGroupDoesNotHaveOneAnchor", - ) - - def test_runtime_flow_coverage_failure_fuses_to_verified_legacy_path( - self, - ) -> None: - system = _system(zero_force_mass_project()) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - original = solver._solve_explicit_flow_unknowns - - def hide_coverage(*args, **kwargs): - original(*args, **kwargs) - return set() - - with patch.object( - solver, - "_solve_explicit_flow_unknowns", - side_effect=hide_coverage, - ): - diagnostics = solver.solve(effort_variables=("p",)) - - self.assertTrue(diagnostics.success) - self.assertTrue(diagnostics.residual_verified_this_solve) - execution = solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "causalRuntimeGateFailed", - ) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_periodic_audit_failure_disables_fast_path_before_fallback(self) -> None: - system = _system(zero_force_mass_project()) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - solver._causal_audit_interval = 0 - original_values = solver._pressure_flow_equation_values - call_count = 0 - - def one_bad_audit_value(): - nonlocal call_count - call_count += 1 - values = original_values() - if call_count != 1: - return values - return (values[0] + 1.0, *values[1:]) - - with patch.object( - solver, - "_pressure_flow_equation_values", - side_effect=one_bad_audit_value, - ): - diagnostics = solver.solve(effort_variables=("p",)) - - self.assertTrue(diagnostics.success) - execution = solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "causalResidualAuditFailed", - ) - self.assertEqual(execution["auditFailureCount"], 1) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_requested_audit_interrupts_periodic_fast_sequence(self) -> None: - system = _system(zero_force_mass_project()) - state = system.initial_state_vector() - solver = system.pressure_flow_solver - - system.rhs(0.0, state) - system.rhs(0.0, state) - before = solver.causal_execution_diagnostics() - self.assertEqual(before["fullResidualAuditCount"], 1) - self.assertEqual(before["fastSolveCount"], 1) - - solver.request_causal_audit() - system.rhs(0.0, state) - after = solver.causal_execution_diagnostics() - self.assertEqual(after["fullResidualAuditCount"], 2) - self.assertEqual(after["fastSolveCount"], 1) - - def test_fast_solve_skips_the_full_residual_evaluator(self) -> None: - system = _system(zero_force_mass_project()) - state = system.initial_state_vector() - solver = system.pressure_flow_solver - system.rhs(0.0, state) - - with patch.object( - solver, - "_pressure_flow_equation_values", - wraps=solver._pressure_flow_equation_values, - ) as evaluate_all: - system.rhs(0.0, state) - - evaluate_all.assert_not_called() - self.assertTrue(solver.last_diagnostics.causal_fast_path_used) - self.assertFalse( - solver.last_diagnostics.residual_verified_this_solve - ) - - def test_nonfinite_explicit_assignment_fuses_and_verifies_same_solve( - self, - ) -> None: - system = _system(zero_force_mass_project()) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - original = solver._evaluate_explicit_flow_stage - call_count = 0 - - def one_nonfinite_assignment(stage): - nonlocal call_count - call_count += 1 - values = original(stage) - if call_count != 1: - return values - return (float("nan"), *values[1:]) - - with patch.object( - solver, - "_evaluate_explicit_flow_stage", - side_effect=one_nonfinite_assignment, - ): - diagnostics = solver.solve(effort_variables=("p",)) - - self.assertTrue(diagnostics.success) - self.assertTrue(diagnostics.residual_verified_this_solve) - execution = solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "causalRuntimeGateFailed", - ) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_nonpositive_pressure_fuses_and_verifies_same_solve(self) -> None: - system = _system(high_pressure_helium_step_project()) - state = system.initial_state_vector() - system.rhs(0.0, state) - solver = system.pressure_flow_solver - original = solver._solve_explicit_flow_unknowns - pressure = next( - unknown for unknown in solver.unknowns if unknown.variable == "p" - ) - - def make_pressure_invalid(*args, **kwargs): - seeded = original(*args, **kwargs) - pressure.write(-1.0) - return seeded - - with patch.object( - solver, - "_solve_explicit_flow_unknowns", - side_effect=make_pressure_invalid, - ): - diagnostics = solver.solve(effort_variables=("p",)) - - self.assertTrue(diagnostics.success) - self.assertTrue(diagnostics.residual_verified_this_solve) - self.assertGreater(pressure.read(), 0.0) - execution = solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "causalRuntimeGateFailed", - ) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_default_periodic_audit_runs_after_sixty_four_fast_solves(self) -> None: - system = _system(zero_force_mass_project()) - state = system.initial_state_vector() - solver = system.pressure_flow_solver - system.rhs(0.0, state) - - for _iteration in range(64): - system.rhs(0.0, state) - before_boundary = solver.causal_execution_diagnostics() - self.assertEqual(before_boundary["auditInterval"], 64) - self.assertEqual(before_boundary["fastSolveCount"], 64) - self.assertEqual(before_boundary["fullResidualAuditCount"], 1) - - system.rhs(0.0, state) - after_boundary = solver.causal_execution_diagnostics() - self.assertEqual(after_boundary["fastSolveCount"], 64) - self.assertEqual(after_boundary["fullResidualAuditCount"], 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_pressure_flow_solver_equation_blocks.py b/tests/test_pressure_flow_solver_equation_blocks.py deleted file mode 100644 index 2f95104..0000000 --- a/tests/test_pressure_flow_solver_equation_blocks.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -from scipy.optimize import least_squares as scipy_least_squares - -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.amesim.flow.pipes import AmesimPnl0002 -from app.simulation.components.amesim.mechanical.translational import ( - AmesimF000, - AmesimForc, - AmesimLstp00a, - AmesimMecmas21, -) -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.medium import IdealGasMedium -from app.simulation.solvers.algebraic import PressureFlowSolver -from app.simulation.systems.network import SimulationNetwork - - -class _CustomPnl0002(AmesimPnl0002): - """External subclass: structural declarations alone are not a purity promise.""" - - -def _branched_solver( - *, - custom_pipe: bool = False, - include_contact: bool = False, -) -> tuple[PressureFlowSolver, AmesimPnl0002]: - medium = IdealGasMedium() - left = Cylinder("left", medium, V=0.1, p0=500_000.0) - right = Tank("right", medium, V=0.1, p0=100_000.0) - pipe_type = _CustomPnl0002 if custom_pipe else AmesimPnl0002 - pipe = pipe_type("pipe", medium, p0=300_000.0, T0=300.0) - isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0) - plug = AmesimPnpl01("isolated_plug") - network = SimulationNetwork("nonlinear-equation-blocks") - for component in (left, right, pipe, isolated, plug): - network.add_component(component) - network.connect("left", "port_b", "pipe", "port_1") - network.connect("pipe", "port_2", "right", "port_a") - network.connect("isolated", "port_b", "isolated_plug", "port_1") - - if include_contact: - force = AmesimForc("contact_force") - force.res.signal = 40.0 - contact = AmesimLstp00a( - "contact", - medium, - gap0=0.0, - kcont=1.0e11, - rcont=0.0, - Pdis=1.0e-7, - discContactOption=1.0, - ) - mass = AmesimMecmas21( - "mass", - medium, - mass=2.0, - useFriction=1.0, - x0=1.0e9, - ) - zero = AmesimF000("zero") - for component in (force, contact, mass, zero): - network.add_component(component) - network.connect("contact_force", "port_2", "contact", "port_1") - network.connect("contact", "port_2", "mass", "port_1") - network.connect("mass", "port_2", "zero", "port_1") - - for component in network.dynamic_components(): - component.refresh_thermodynamic_ports() - solver = PressureFlowSolver(network) - solver.solve() - return solver, pipe - - -def _damage_flows_after_seed( - solver: PressureFlowSolver, - pipe: AmesimPnl0002, - ports: tuple[str, ...], - *, - snapshots: list[tuple[float, ...]] | None = None, -) -> None: - original = solver._solve_explicit_flow_unknowns - - def damaged(*args, **kwargs): - result = original(*args, **kwargs) - for index, port_name in enumerate(ports, start=1): - port = pipe.get_port(port_name) - port.m_flow += index * 0.01 - if snapshots is not None: - snapshots.append(tuple(unknown.read() for unknown in solver.unknowns)) - return result - - solver._solve_explicit_flow_unknowns = damaged - - -class PressureFlowEquationBlockTests(unittest.TestCase): - def test_pnl0002_owner_is_safely_split_across_two_blocks(self) -> None: - solver, _pipe = _branched_solver() - pipe_rows = { - index - for index, equation in enumerate(solver.equation_templates) - if equation.owner == "component" and equation.owner_id == "pipe" - } - owner_blocks = [ - block - for block in solver.equation_blocks - if pipe_rows.intersection(block.equation_indices) - ] - - self.assertTrue(solver.equation_blocks_are_trusted) - self.assertEqual(len(pipe_rows), 2) - self.assertEqual(len(owner_blocks), 2) - self.assertTrue( - set(owner_blocks[0].unknown_indices).isdisjoint( - owner_blocks[1].unknown_indices - ) - ) - - def test_only_bad_block_uses_scoped_sparse_residual(self) -> None: - optimized, optimized_pipe = _branched_solver() - dense, dense_pipe = _branched_solver() - _damage_flows_after_seed(optimized, optimized_pipe, ("port_1",)) - _damage_flows_after_seed(dense, dense_pipe, ("port_1",)) - dense._equation_blocks_are_trusted = False - dense._jacobian_sparsity_is_trusted = False - - block_diagnostics = optimized.solve() - dense_diagnostics = dense.solve() - - self.assertEqual(block_diagnostics.jacobian_mode, "blockSparse") - self.assertEqual(block_diagnostics.nonlinear_block_count, 1) - self.assertEqual(block_diagnostics.nonlinear_block_unknown_count, 4) - self.assertFalse(block_diagnostics.block_fallback_used) - self.assertLess( - block_diagnostics.residual_evaluations, - dense_diagnostics.residual_evaluations, - ) - for block_unknown, dense_unknown in zip( - optimized.unknowns, - dense.unknowns, - ): - self.assertAlmostEqual( - block_unknown.read(), - dense_unknown.read(), - delta=1.0e-6 * max(abs(dense_unknown.read()), 1.0), - ) - - def test_multiple_bad_blocks_are_solved_in_one_union_call(self) -> None: - solver, pipe = _branched_solver() - _damage_flows_after_seed(solver, pipe, ("port_1", "port_2")) - - with patch( - "scipy.optimize.least_squares", - wraps=scipy_least_squares, - ) as least_squares: - diagnostics = solver.solve() - - self.assertEqual(least_squares.call_count, 1) - self.assertEqual(diagnostics.jacobian_mode, "blockSparse") - self.assertEqual(diagnostics.nonlinear_block_count, 2) - self.assertEqual(diagnostics.nonlinear_block_unknown_count, 8) - subset_pattern = least_squares.call_args.kwargs["jac_sparsity"] - self.assertEqual(subset_pattern.shape, (8, 8)) - self.assertLess(subset_pattern.nnz, solver.jacobian_sparsity.nnz) - - def test_failed_local_candidate_restores_global_seed_before_fallback( - self, - ) -> None: - solver, pipe = _branched_solver() - seeded_snapshots: list[tuple[float, ...]] = [] - _damage_flows_after_seed( - solver, - pipe, - ("port_1",), - snapshots=seeded_snapshots, - ) - call_count = 0 - - def fail_block_then_solve_global(fun, x0, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - self.assertEqual(x0.shape, (4,)) - return SimpleNamespace( - x=x0 + 123.0, - success=False, - status=-1, - message="forced block failure", - nfev=1, - ) - - self.assertEqual(x0.shape, (len(solver.unknowns),)) - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - seeded_snapshots[-1], - ) - return scipy_least_squares(fun, x0, **kwargs) - - with patch( - "scipy.optimize.least_squares", - side_effect=fail_block_then_solve_global, - ): - diagnostics = solver.solve() - - self.assertGreaterEqual(call_count, 2) - self.assertTrue(diagnostics.success) - self.assertTrue(diagnostics.block_fallback_used) - self.assertEqual(diagnostics.nonlinear_block_count, 1) - self.assertEqual( - diagnostics.block_fallback_reason, - "blockResidualNotConverged", - ) - - def test_block_memory_error_restores_seed_and_remains_fatal(self) -> None: - solver, pipe = _branched_solver() - seeded_snapshots: list[tuple[float, ...]] = [] - _damage_flows_after_seed( - solver, - pipe, - ("port_1",), - snapshots=seeded_snapshots, - ) - - with patch( - "scipy.optimize.least_squares", - side_effect=MemoryError("forced allocation failure"), - ): - with self.assertRaises(MemoryError): - solver.solve() - - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - seeded_snapshots[-1], - ) - - def test_custom_component_keeps_the_legacy_global_path(self) -> None: - solver, pipe = _branched_solver(custom_pipe=True) - _damage_flows_after_seed(solver, pipe, ("port_1",)) - - diagnostics = solver.solve() - - self.assertFalse(solver.equation_blocks_are_trusted) - self.assertEqual( - solver.equation_blocks_fallback_reason, - "untrustedCustomComponent", - ) - self.assertEqual(diagnostics.jacobian_mode, "dense") - self.assertTrue(diagnostics.block_fallback_used) - self.assertEqual( - diagnostics.block_fallback_reason, - "untrustedCustomComponent", - ) - self.assertFalse(solver.causal_fast_path_eligible) - self.assertEqual( - solver.causal_execution_diagnostics()["fallbackReason"], - "untrustedCustomComponent", - ) - - def test_active_contact_conservatively_keeps_global_dense_fallback(self) -> None: - solver, pipe = _branched_solver(include_contact=True) - _damage_flows_after_seed(solver, pipe, ("port_1",)) - - diagnostics = solver.solve() - - self.assertEqual(diagnostics.jacobian_mode, "dense") - self.assertTrue(diagnostics.block_fallback_used) - self.assertEqual( - diagnostics.block_fallback_reason, - "activeCausalContact", - ) - contact = solver.network.components["contact"] - self.assertIsNotNone(contact._causal_penetration) - self.assertAlmostEqual(contact.contact_force, 40.0, places=7) - self.assertFalse(solver.causal_fast_path_eligible) - self.assertEqual( - solver.causal_execution_diagnostics()["fallbackReason"], - "activeSetCausalizationRequired", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_pressure_flow_solver_initialization.py b/tests/test_pressure_flow_solver_initialization.py deleted file mode 100644 index e78fea6..0000000 --- a/tests/test_pressure_flow_solver_initialization.py +++ /dev/null @@ -1,506 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.amesim.flow.orifices import ( - AmesimPnor001, - AmesimPnvo001FixedOpening, - AmesimPnvo001SignalOpening, -) -from app.simulation.components.amesim.flow.pipes import AmesimPnl00r -from app.simulation.components.amesim.flow.pipes import AmesimPnl0001 -from app.simulation.components.amesim.flow.pipes import AmesimPnl0002 -from app.simulation.components.amesim.junctions.nodes import AmesimPn3Node2 -from app.simulation.components.amesim.media.mediums import ( - AmesimHeliumPengRobinsonMedium, -) -from app.simulation.components.amesim.storage.chambers import AmesimPnch023 -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.medium import IdealGasMedium -from app.simulation.core.state import VolumeState -from app.simulation.solvers.algebraic import AlgebraicSolveError, PressureFlowSolver -from app.simulation.systems.generic import GenericFluidSystem -from app.simulation.systems.network import SimulationNetwork - - -class PressureFlowSolverInitializationTests(unittest.TestCase): - def test_pnor_pnl0001_series_pressure_is_seeded_by_flow_balance(self) -> None: - medium = IdealGasMedium() - chamber = AmesimPnch023("source", medium, p0=15.3e6) - source_plug = AmesimPnpl01("source_closed") - orifice = AmesimPnor001("orifice", medium) - pipe = AmesimPnl0001("pipe", medium, p0=14.0e6) - storage_plug = AmesimPnpl01("storage_closed") - network = SimulationNetwork("pnor-pnl0001-series") - for component in (chamber, source_plug, orifice, pipe, storage_plug): - network.add_component(component) - network.connect("source_closed", "port_1", "source", "port_1") - network.connect("source", "port_2", "orifice", "port_1") - network.connect("orifice", "port_2", "pipe", "port_1") - network.connect("pipe", "port_2", "storage_closed", "port_1") - chamber.refresh_thermodynamic_ports() - pipe.refresh_thermodynamic_ports() - - solver = PressureFlowSolver(network) - result = solver.solve() - - self.assertTrue(result.success) - self.assertGreater(orifice.port_2.p, pipe.port_2.p) - self.assertLess(orifice.port_2.p, chamber.port_2.p) - self.assertAlmostEqual(orifice.port_2.p, pipe.port_1.p) - self.assertAlmostEqual(-orifice.port_2.m_flow, pipe.port_1.m_flow) - self.assertFalse(solver.causal_fast_path_eligible) - self.assertEqual( - solver.causal_execution_diagnostics()["fallbackReason"], - "specialSeriesPressureSeed", - ) - - def test_pnvo_pnl0001_series_pressure_is_seeded_by_flow_balance(self) -> None: - medium = IdealGasMedium() - chamber = AmesimPnch023("source", medium, p0=15.3e6) - source_plug = AmesimPnpl01("source_closed") - valve = AmesimPnvo001SignalOpening( - "valve", - medium, - opening0=1.0, - ) - valve.res.signal = 1.0 - pipe = AmesimPnl0001("pipe", medium, p0=14.0e6) - storage_plug = AmesimPnpl01("storage_closed") - network = SimulationNetwork("pnvo-pnl0001-series") - for component in (chamber, source_plug, valve, pipe, storage_plug): - network.add_component(component) - network.connect("source_closed", "port_1", "source", "port_1") - network.connect("source", "port_2", "valve", "port_3") - network.connect("valve", "port_2", "pipe", "port_1") - network.connect("pipe", "port_2", "storage_closed", "port_1") - chamber.refresh_thermodynamic_ports() - pipe.refresh_thermodynamic_ports() - - solver = PressureFlowSolver(network) - result = solver.solve() - - self.assertTrue(result.success) - self.assertEqual(result.evaluations, 0) - self.assertGreater(valve.port_2.p, pipe.port_2.p) - self.assertLess(valve.port_2.p, chamber.port_2.p) - self.assertAlmostEqual(valve.port_2.p, pipe.port_1.p) - self.assertAlmostEqual(-valve.port_2.m_flow, pipe.port_1.m_flow) - - previous_intermediate_pressure = valve.port_2.p - replay_pressure = 12.0e6 - replay_temperature = 293.15 - replay_mass = ( - medium.density(replay_pressure, replay_temperature) * pipe.volume - ) - pipe.state = VolumeState( - m=replay_mass, - U=replay_mass * medium.specific_internal_energy(replay_temperature), - ) - pipe.refresh_thermodynamic_ports() - - replay = solver.solve() - - self.assertTrue(replay.success) - self.assertEqual(replay.evaluations, 0) - self.assertNotAlmostEqual(valve.port_2.p, previous_intermediate_pressure) - self.assertAlmostEqual(valve.port_2.p, pipe.port_1.p) - self.assertAlmostEqual(-valve.port_2.m_flow, pipe.port_1.m_flow) - - def test_pnvo_pnl00r_series_pressure_is_seeded_by_flow_balance(self) -> None: - medium = IdealGasMedium() - source = Cylinder( - "source", - medium, - V=0.01, - p0=150_000.0, - T0=300.0, - ) - valve = AmesimPnvo001FixedOpening( - "valve", - medium, - opening=0.5, - ) - pipe = AmesimPnl00r( - "pipe", - medium, - diam=0.02, - le=1.0, - rr=1.0e-5, - ) - sink = Tank( - "sink", - medium, - V=0.1, - p0=100_000.0, - T0=300.0, - ) - network = SimulationNetwork("pnvo-pnl00r-series") - for component in (source, valve, pipe, sink): - network.add_component(component) - network.connect("source", "port_b", "valve", "port_2") - network.connect("valve", "port_3", "pipe", "port_1") - network.connect("pipe", "port_2", "sink", "port_a") - source.refresh_thermodynamic_ports() - sink.refresh_thermodynamic_ports() - - solver = PressureFlowSolver(network) - result = solver.solve() - - self.assertTrue(result.success) - self.assertEqual(result.evaluations, 0) - self.assertGreater(valve.port_3.p, pipe.port_2.p) - self.assertLess(valve.port_3.p, source.port_b.p) - self.assertAlmostEqual(valve.port_3.p, pipe.port_1.p) - self.assertAlmostEqual( - -valve.port_3.m_flow, - pipe.port_1.m_flow, - delta=1.0e-10, - ) - self.assertFalse(solver.causal_fast_path_eligible) - self.assertEqual( - solver.causal_execution_diagnostics()["fallbackReason"], - "specialSeriesPressureSeed", - ) - - def test_dead_ended_pnl00r_is_seeded_at_zero_flow_pressure(self) -> None: - medium = IdealGasMedium() - pipe = AmesimPnl00r("resistance", medium) - plug = AmesimPnpl01("closed") - pipe.port_1.p = 15.3e6 - pipe.port_2.p = 100_000.0 - network = SimulationNetwork("dead-ended-resistance") - network.add_component(pipe) - network.add_component(plug) - network.connect("resistance", "port_2", "closed", "port_1") - - solver = PressureFlowSolver(network) - result = solver.solve() - - self.assertTrue(result.success) - self.assertEqual(result.evaluations, 0) - self.assertAlmostEqual(pipe.port_2.p, pipe.port_1.p) - self.assertEqual(pipe.port_1.m_flow, 0.0) - self.assertEqual(pipe.port_2.m_flow, 0.0) - self.assertFalse(solver.causal_fast_path_eligible) - self.assertEqual( - solver.causal_execution_diagnostics()["fallbackReason"], - "specialClosedResistancePressureSeed", - ) - - @staticmethod - def _near_equal_pressure_network() -> tuple[ - SimulationNetwork, - AmesimHeliumPengRobinsonMedium, - Cylinder, - Tank, - AmesimPnvo001SignalOpening, - ]: - medium = AmesimHeliumPengRobinsonMedium() - high = Cylinder("high", medium, V=0.057, p0=1.0e5, T0=256.1) - low = Tank("low", medium, V=0.015, p0=1.0e5, T0=298.2) - valve = AmesimPnvo001SignalOpening( - "valve", - medium, - cq=0.45, - area0=7.85e-5, - gi=1.0, - flowset=1.0, - opening0=1.0, - ) - valve.res.signal = 1.0 - - network = SimulationNetwork("near-equal-pressure") - for component in (high, low, valve): - network.add_component(component) - network.connect("high", "port_b", "valve", "port_2") - network.connect("valve", "port_3", "low", "port_a") - return network, medium, high, low, valve - - @staticmethod - def _set_pressure_temperature( - component: Cylinder | Tank, - medium: AmesimHeliumPengRobinsonMedium, - pressure: float, - temperature: float, - ) -> None: - mass = medium.density(pressure, temperature) * component.V - component.state = VolumeState( - m=mass, - U=mass - * medium.specific_internal_energy_at_pressure( - pressure, - temperature, - ), - ) - component.refresh_thermodynamic_ports() - - def test_stream_enthalpy_refreshes_explicit_orifice_flow(self) -> None: - network, medium, high, low, valve = self._near_equal_pressure_network() - self._set_pressure_temperature( - high, - medium, - 15_201_996.778497815, - 292.3997890954483, - ) - self._set_pressure_temperature( - low, - medium, - 405_072.8123335606, - 393.46713105173205, - ) - - system = GenericFluidSystem(network) - system.consistent_initial_state_vector() - - self.assertLess(valve._connected_h["port_2"], 0.0) - self.assertAlmostEqual( - valve.port_2.m_flow, - valve.mass_flow(valve.port_2.p, valve.port_3.p), - places=12, - ) - self.assertAlmostEqual(valve.port_3.m_flow, -valve.port_2.m_flow, places=12) - - def test_stream_dependent_pipe_flow_closes_with_junction_mixing_in_one_rhs(self) -> None: - medium = IdealGasMedium() - hot = Cylinder("hot", medium, V=0.1, p0=500_000.0, T0=400.0) - cold = Cylinder("cold", medium, V=0.1, p0=500_000.0, T0=250.0) - sink = Tank("sink", medium, V=0.1, p0=100_000.0, T0=300.0) - hot_line = AmesimPnl00r("hot_line", medium, diam=0.01, le=0.5) - cold_line = AmesimPnl00r("cold_line", medium, diam=0.01, le=0.5) - junction = AmesimPn3Node2("junction") - pipe = AmesimPnl0002( - "pipe", - medium, - diam=0.01, - le=1.0, - p0=200_000.0, - T0=300.0, - ) - network = SimulationNetwork("stream-flow-fixed-point") - for component in ( - hot, - cold, - sink, - hot_line, - cold_line, - junction, - pipe, - ): - network.add_component(component) - network.connect("hot", "port_b", "hot_line", "port_1") - network.connect("hot_line", "port_2", "junction", "port_1") - network.connect("cold", "port_b", "cold_line", "port_1") - network.connect("cold_line", "port_2", "junction", "port_3") - network.connect("junction", "port_2", "pipe", "port_1") - network.connect("pipe", "port_2", "sink", "port_a") - - system = GenericFluidSystem(network) - state = system.consistent_initial_state_vector() - first = system.rhs(0.0, state) - second = system.rhs(0.0, state) - - # PN3NODE2 takes its pressure-flow temperature reference from port 2, - # so that closure no longer depends on the flow-weighted energy mix. - self.assertGreaterEqual(system.max_thermofluid_iterations, 1) - for first_value, second_value in zip(first, second): - self.assertAlmostEqual(first_value, second_value, delta=1.0e-8) - - def test_current_storage_pressure_reseeds_stale_orifice_ports_and_flow(self) -> None: - network, medium, high, low, valve = self._near_equal_pressure_network() - solver = PressureFlowSolver(network, max_evaluations=10) - high_pressure = 10_790_000.0 - - self._set_pressure_temperature(high, medium, high_pressure, 256.1) - self._set_pressure_temperature(low, medium, high_pressure - 300.0, 298.2) - initial = solver.solve() - self.assertTrue(initial.success) - - stale_low_pressure = valve.port_3.p - stale_flow = valve.port_2.m_flow - self._set_pressure_temperature(low, medium, high_pressure - 100.0, 298.2) - self.assertNotAlmostEqual(low.port_a.p, stale_low_pressure, places=3) - - updated = solver.solve() - - self.assertTrue(updated.success) - self.assertEqual(updated.evaluations, 0) - self.assertAlmostEqual(valve.port_2.p, high.port_b.p, places=6) - self.assertAlmostEqual(valve.port_3.p, low.port_a.p, places=6) - self.assertNotAlmostEqual(valve.port_2.m_flow, stale_flow, places=8) - self.assertAlmostEqual( - valve.port_2.m_flow, - valve.mass_flow(valve.port_2.p, valve.port_3.p), - places=10, - ) - self.assertAlmostEqual(high.port_b.m_flow, -valve.port_2.m_flow, places=10) - self.assertAlmostEqual(low.port_a.m_flow, -valve.port_3.m_flow, places=10) - - def test_compiled_plan_does_not_rebuild_equation_metadata_during_solve(self) -> None: - boundary = AmesimPnpl01("compiled_closed") - boundary.port_1.p = 100_000.0 - boundary.port_1.m_flow = 1.0 - network = SimulationNetwork("compiled-closed-boundary") - network.add_component(boundary) - solver = PressureFlowSolver(network) - - with patch.object( - boundary, - "pressure_flow_equation_residuals", - side_effect=AssertionError("equation metadata rebuilt at runtime"), - ): - diagnostics = solver.solve() - - self.assertTrue(diagnostics.success) - self.assertEqual(diagnostics.evaluations, 0) - self.assertEqual(boundary.port_1.m_flow, 0.0) - - @staticmethod - def _closed_boundary_solver() -> PressureFlowSolver: - boundary = AmesimPnpl01("closed") - boundary.port_1.p = 100_000.0 - boundary.port_1.m_flow = 1.0 - network = SimulationNetwork("closed-boundary") - network.add_component(boundary) - return PressureFlowSolver(network) - - @staticmethod - def _least_squares_result(x, *, status: int): - return SimpleNamespace( - x=x, - success=status > 0, - status=status, - message="test optimizer result", - nfev=1, - ) - - def test_status_zero_is_accepted_only_for_finite_converged_residuals(self) -> None: - exact_solver = self._closed_boundary_solver() - - def exact_status_zero(_fun, x0, **_kwargs): - values = x0.copy() - flow_index = next( - index - for index, unknown in enumerate(exact_solver.unknowns) - if unknown.variable == "m_flow" - ) - values[flow_index] = 0.0 - return self._least_squares_result(values, status=0) - - with patch.object( - PressureFlowSolver, - "_solve_explicit_flow_unknowns", - return_value=None, - ), patch("scipy.optimize.least_squares", side_effect=exact_status_zero): - diagnostics = exact_solver.solve() - self.assertTrue(diagnostics.success) - self.assertEqual(diagnostics.max_scaled_residual, 0.0) - - inaccurate_solver = self._closed_boundary_solver() - - def inaccurate_status_zero(_fun, x0, **_kwargs): - values = x0.copy() - flow_index = next( - index - for index, unknown in enumerate(inaccurate_solver.unknowns) - if unknown.variable == "m_flow" - ) - values[flow_index] = 1.0 - return self._least_squares_result(values, status=0) - - with patch.object( - PressureFlowSolver, - "_solve_explicit_flow_unknowns", - return_value=None, - ), patch("scipy.optimize.least_squares", side_effect=inaccurate_status_zero): - with self.assertRaises(AlgebraicSolveError): - inaccurate_solver.solve() - - invalid_status_solver = self._closed_boundary_solver() - - def exact_invalid_status(_fun, x0, **_kwargs): - values = x0.copy() - flow_index = next( - index - for index, unknown in enumerate(invalid_status_solver.unknowns) - if unknown.variable == "m_flow" - ) - values[flow_index] = 0.0 - return self._least_squares_result(values, status=-1) - - with patch.object( - PressureFlowSolver, - "_solve_explicit_flow_unknowns", - return_value=None, - ), patch("scipy.optimize.least_squares", side_effect=exact_invalid_status): - with self.assertRaises(AlgebraicSolveError): - invalid_status_solver.solve() - - def test_zero_residual_seed_does_not_bypass_positive_pressure_bound(self) -> None: - medium = IdealGasMedium() - tank = Tank("tank", medium, V=1.0) - plug = AmesimPnpl01("plug") - network = SimulationNetwork("invalid-negative-pressure") - network.add_component(tank) - network.add_component(plug) - network.connect("tank", "port_a", "plug", "port_1") - - tank.state = VolumeState(m=1.0, U=-1000.0) - tank.refresh_thermodynamic_ports() - self.assertLess(tank.port_a.p, 0.0) - - solver = PressureFlowSolver(network, max_evaluations=10) - with self.assertRaises(AlgebraicSolveError): - solver.solve() - self.assertIsNotNone(solver.last_diagnostics) - self.assertFalse(solver.last_diagnostics.success) - - def test_positive_sub_pascal_seed_uses_the_exact_pressure_without_optimizer( - self, - ) -> None: - medium = IdealGasMedium() - pressure = 0.43301848566882734 - temperature = pressure / medium.R_gas - tank = Tank("tank", medium, V=1.0) - plug = AmesimPnpl01("plug") - network = SimulationNetwork("positive-sub-pascal-pressure") - network.add_component(tank) - network.add_component(plug) - network.connect("tank", "port_a", "plug", "port_1") - - tank.state = VolumeState( - m=1.0, - U=medium.specific_internal_energy(temperature), - ) - tank.refresh_thermodynamic_ports() - self.assertAlmostEqual( - tank.port_a.p, - pressure, - delta=pressure * 1.0e-11, - ) - - solver = PressureFlowSolver(network, max_evaluations=10) - with patch( - "scipy.optimize.least_squares", - side_effect=AssertionError( - "A finite positive pressure seed must stay on the fast path." - ), - ) as least_squares: - diagnostics = solver.solve() - - self.assertTrue(diagnostics.success) - self.assertEqual(diagnostics.evaluations, 0) - self.assertAlmostEqual( - tank.port_a.p, - pressure, - delta=pressure * 1.0e-11, - ) - least_squares.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_pressure_flow_solver_sparsity.py b/tests/test_pressure_flow_solver_sparsity.py deleted file mode 100644 index acbdc2e..0000000 --- a/tests/test_pressure_flow_solver_sparsity.py +++ /dev/null @@ -1,210 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -import numpy as np - -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.core.base import AlgebraicComponent -from app.simulation.core.equations import EquationResidual -from app.simulation.core.medium import IdealGasMedium -from app.simulation.core.ports import PortDefinition -from app.simulation.solvers.algebraic import PressureFlowSolver -from app.simulation.systems.network import SimulationNetwork - - -class _IncompleteDependencyComponent(AlgebraicComponent): - """Deliberately violate the residual dependency authoring contract.""" - - PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),) - - def __init__(self, name: str) -> None: - super().__init__(name) - self.port_1 = self.register_declared_port("port_1") - self.port_1.p = 90_000.0 - self.port_1.m_flow = 1.0 - - def pressure_flow_equation_values(self) -> tuple[float, ...]: - return self.port_1.p - 100_000.0, self.port_1.m_flow - - def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]: - return ( - EquationResidual( - id=f"{self.name}:pressure", - owner="component", - owner_id=self.name, - relation="state", - # The live residual reads p, but the declaration omits it on - # purpose. A zero structural row must disable sparse FD. - variables=(), - role="effort", - value=self.port_1.p - 100_000.0, - ), - EquationResidual( - id=f"{self.name}:flow", - owner="component", - owner_id=self.name, - relation="constitutive", - variables=(f"{self.name}.port_1.m_flow",), - role="flow", - value=self.port_1.m_flow, - ), - ) - - -def _storage_boundary_solver(name: str) -> PressureFlowSolver: - medium = IdealGasMedium() - storage = Cylinder(f"{name}_storage", medium, V=0.1, p0=500_000.0) - boundary = AmesimPnpl01(f"{name}_boundary") - network = SimulationNetwork(name) - network.add_component(storage) - network.add_component(boundary) - network.connect(storage.name, "port_b", boundary.name, "port_1") - storage.refresh_thermodynamic_ports() - boundary.port_1.m_flow = 1.0 - return PressureFlowSolver(network) - - -class PressureFlowSolverSparsityTests(unittest.TestCase): - def test_sparse_and_dense_paths_produce_the_same_solution(self) -> None: - sparse_solver = _storage_boundary_solver("sparse") - dense_solver = _storage_boundary_solver("dense") - dense_solver._jacobian_sparsity_is_trusted = False - - with patch.object( - sparse_solver, - "_solve_explicit_flow_unknowns", - return_value=set(), - ): - sparse = sparse_solver.solve() - with patch.object( - dense_solver, - "_solve_explicit_flow_unknowns", - return_value=set(), - ): - dense = dense_solver.solve() - - self.assertEqual(sparse.jacobian_mode, "blockSparse") - self.assertEqual(sparse.nonlinear_block_count, 1) - self.assertLess( - sparse.nonlinear_block_unknown_count, - len(sparse_solver.unknowns), - ) - self.assertEqual(dense.jacobian_mode, "dense") - self.assertFalse(sparse.dense_fallback_used) - self.assertFalse(dense.dense_fallback_used) - self.assertGreater(sparse.residual_evaluations, sparse.evaluations) - self.assertLess(sparse.residual_evaluations, dense.residual_evaluations) - sparse_values = { - unknown.id.split("_", maxsplit=1)[-1]: unknown.read() - for unknown in sparse_solver.unknowns - } - dense_values = { - unknown.id.split("_", maxsplit=1)[-1]: unknown.read() - for unknown in dense_solver.unknowns - } - self.assertEqual(sparse_values.keys(), dense_values.keys()) - for variable_id in sparse_values: - self.assertAlmostEqual( - sparse_values[variable_id], - dense_values[variable_id], - delta=1.0e-9, - ) - self.assertEqual( - sparse.as_dict()["residualEvaluations"], - sparse.residual_evaluations, - ) - - def test_incomplete_dependency_metadata_uses_dense_finite_differences( - self, - ) -> None: - component = _IncompleteDependencyComponent("incomplete") - network = SimulationNetwork("incomplete-dependency") - network.add_component(component) - solver = PressureFlowSolver(network) - - diagnostics = solver.solve() - - self.assertFalse(solver.jacobian_sparsity_is_trusted) - self.assertEqual( - solver.jacobian_sparsity_fallback_reason, - "equationWithoutDeclaredUnknown", - ) - self.assertEqual(diagnostics.jacobian_mode, "dense") - self.assertFalse(diagnostics.dense_fallback_used) - self.assertAlmostEqual(component.port_1.p, 100_000.0, places=6) - self.assertAlmostEqual(component.port_1.m_flow, 0.0, places=12) - - def test_failed_sparse_candidate_restarts_dense_from_original_x0(self) -> None: - solver = _storage_boundary_solver("retry") - # This regression specifically exercises the final whole-network - # sparse -> dense compatibility fallback, not the preceding block - # optimization. - solver._equation_blocks_are_trusted = False - original_x0: list[np.ndarray] = [] - calls: list[str] = [] - flow_indices = tuple( - index - for index, unknown in enumerate(solver.unknowns) - if unknown.variable == "m_flow" - ) - - def fake_least_squares(_fun, x0, **kwargs): - if "jac_sparsity" in kwargs: - calls.append("sparse") - original_x0.append(x0.copy()) - failed = x0.copy() - failed[list(flow_indices)] = 123.0 - return SimpleNamespace( - x=failed, - success=False, - status=-1, - message="forced sparse failure", - nfev=3, - ) - - calls.append("dense") - np.testing.assert_array_equal(x0, original_x0[0]) - solved = x0.copy() - solved[list(flow_indices)] = 0.0 - return SimpleNamespace( - x=solved, - success=True, - status=1, - message="dense fallback solved", - nfev=4, - ) - - with patch.object( - solver, - "_solve_explicit_flow_unknowns", - return_value=set(), - ), patch( - "scipy.optimize.least_squares", - side_effect=fake_least_squares, - ): - diagnostics = solver.solve() - - self.assertEqual(calls, ["sparse", "dense"]) - self.assertTrue(diagnostics.success) - self.assertEqual(diagnostics.evaluations, 7) - self.assertEqual(diagnostics.jacobian_mode, "sparseThenDense") - self.assertTrue(diagnostics.dense_fallback_used) - for index in flow_indices: - self.assertAlmostEqual(solver.unknowns[index].read(), 0.0, places=12) - - def test_compiled_pattern_matches_declared_storage_boundary_structure( - self, - ) -> None: - solver = _storage_boundary_solver("pattern") - - self.assertTrue(solver.jacobian_sparsity_is_trusted) - self.assertEqual(solver.jacobian_sparsity.shape, (4, 4)) - self.assertEqual(solver.jacobian_sparsity.nnz, 6) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_property_performance_instrumentation.py b/tests/test_property_performance_instrumentation.py deleted file mode 100644 index e1f7aa2..0000000 --- a/tests/test_property_performance_instrumentation.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -import unittest -from unittest.mock import Mock, patch - -from app.simulation.components.amesim.media.mediums import ( - AmesimHeliumPengRobinsonMedium, -) -from app.simulation.property_cache import property_cache_run - - -class PropertyPerformanceInstrumentationTests(unittest.TestCase): - def setUp(self) -> None: - self.medium = AmesimHeliumPengRobinsonMedium() - - def test_temperature_iterations_are_recorded_only_on_cache_miss(self) -> None: - pressure = 15.3e6 - temperature = 293.15 - enthalpy = self.medium.specific_enthalpy_at_pressure( - pressure, - temperature, - ) - - with patch( - "app.simulation.components.amesim.media.mediums." - "record_property_iterations" - ) as record_iterations: - with property_cache_run(): - first = self.medium.temperature_from_pressure_enthalpy( - pressure, - enthalpy, - ) - second = self.medium.temperature_from_pressure_enthalpy( - pressure, - enthalpy, - ) - - self.assertEqual(second, first) - record_iterations.assert_called_once() - operation, iterations, converged = record_iterations.call_args.args - self.assertEqual(operation, "temperature_from_pressure_enthalpy") - self.assertEqual(iterations, 5) - self.assertTrue(converged) - - def test_state_recovery_iterations_are_recorded_only_on_cache_miss(self) -> None: - pressure = 15.3e6 - temperature = 293.15 - volume = 0.057 - mass = self.medium.density(pressure, temperature) * volume - internal_energy = mass * self.medium.specific_internal_energy_at_pressure( - pressure, - temperature, - ) - - with patch( - "app.simulation.components.amesim.media.mediums." - "record_property_iterations" - ) as record_iterations: - with property_cache_run(): - first = self.medium.properties_from_mU( - mass, - internal_energy, - volume, - ) - second = self.medium.properties_from_mU( - mass, - internal_energy, - volume, - ) - - self.assertIs(second, first) - record_iterations.assert_called_once() - operation, iterations, converged = record_iterations.call_args.args - self.assertEqual(operation, "properties_from_mU") - self.assertEqual(iterations, 5) - self.assertTrue(converged) - - def test_temperature_iteration_limit_is_reported_as_not_converged(self) -> None: - pressure = 123_456.0 - base_temperature = 300.0 - enthalpy = self.medium.specific_enthalpy(base_temperature) - fake_fluid = Mock() - fake_fluid.residual_specific_enthalpy.side_effect = ( - lambda _pressure, temperature: ( - self.medium.cp_ref * 10.0 - if temperature >= base_temperature - else -self.medium.cp_ref * 10.0 - ) - ) - - with ( - patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid), - patch( - "app.simulation.components.amesim.media.mediums." - "record_property_iterations" - ) as record_iterations, - ): - self.medium.temperature_from_pressure_enthalpy( - pressure, - enthalpy, - ) - - record_iterations.assert_called_once_with( - "temperature_from_pressure_enthalpy", - 16, - False, - ) - - def test_state_recovery_iteration_limit_is_reported_as_not_converged(self) -> None: - base_temperature = 300.0 - target_internal_energy = self.medium.specific_internal_energy( - base_temperature - ) - fake_fluid = Mock() - fake_fluid.residual_specific_internal_energy_at_density.side_effect = ( - lambda temperature, _density: ( - self.medium.cv * 10.0 - if temperature >= base_temperature - else -self.medium.cv * 10.0 - ) - ) - fake_fluid.pressure_from_density.return_value = 101_325.0 - fake_fluid.residual_specific_enthalpy.return_value = 0.0 - - with ( - patch.object(AmesimHeliumPengRobinsonMedium, "fluid", fake_fluid), - patch( - "app.simulation.components.amesim.media.mediums." - "record_property_iterations" - ) as record_iterations, - ): - self.medium.properties_from_mU( - 1.0, - target_internal_energy, - 1.0, - ) - - record_iterations.assert_called_once_with( - "properties_from_mU", - 16, - False, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_run_test_mql_baseline.py b/tests/test_run_test_mql_baseline.py deleted file mode 100644 index 3c5fff9..0000000 --- a/tests/test_run_test_mql_baseline.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.run_baseline import ( - TestMqlBaselineExecutionConfig, - TestMqlBaselinePathConfig, - TestMqlBaselineScriptConfig, - format_test_mql_baseline_summary, - run_test_mql_baseline, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class RunTestMqlBaselineScriptTests(unittest.TestCase): - def test_baseline_script_writes_summary_for_selected_paths(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) - run, returned_output_dir = run_test_mql_baseline( - TestMqlBaselineScriptConfig( - paths=TestMqlBaselinePathConfig( - archive_path=TEST_MQL_AME, - output_dir=output_dir, - ), - execution=TestMqlBaselineExecutionConfig( - data_paths=("press@pn_c1_8", "vol1@pn_brp2_8"), - ), - ) - ) - - summary_path = output_dir / "test_mql_baseline_summary.txt" - summary = summary_path.read_text(encoding="utf-8") - - self.assertEqual(returned_output_dir, output_dir) - self.assertEqual(run.sample_count, 1002) - self.assertEqual(run.signal_count, 2) - self.assertEqual(run.comparison.max_abs_error, 0.0) - self.assertIn("Mode: AMESim baseline passthrough", summary) - self.assertIn("Samples: 1002", summary) - self.assertIn("Compared signals: 2", summary) - self.assertIn("Max absolute error: 0.0", summary) - - def test_baseline_script_can_skip_summary_file(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) - run, returned_output_dir = run_test_mql_baseline( - TestMqlBaselineScriptConfig( - paths=TestMqlBaselinePathConfig( - archive_path=TEST_MQL_AME, - output_dir=output_dir, - ), - execution=TestMqlBaselineExecutionConfig( - write_summary=False, - data_paths=("press@pn_c1_8",), - ), - ) - ) - - self.assertEqual(returned_output_dir, output_dir) - self.assertEqual(run.signal_count, 1) - self.assertFalse((output_dir / "test_mql_baseline_summary.txt").exists()) - - def test_format_test_mql_baseline_summary(self) -> None: - run, _ = run_test_mql_baseline( - TestMqlBaselineScriptConfig( - paths=TestMqlBaselinePathConfig(archive_path=TEST_MQL_AME), - execution=TestMqlBaselineExecutionConfig( - write_summary=False, - data_paths=("press@pn_c1_8",), - ), - ) - ) - - summary = format_test_mql_baseline_summary(run) - - self.assertIn("Model: test_mql", summary) - self.assertIn("Output schema signals: 858", summary) - self.assertIn("Compared signals: 1", summary) - self.assertTrue(summary.endswith("\n")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_run_test_mql_full_state_comparison.py b/tests/test_run_test_mql_full_state_comparison.py deleted file mode 100644 index ecbc9aa..0000000 --- a/tests/test_run_test_mql_full_state_comparison.py +++ /dev/null @@ -1,1024 +0,0 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.solver import SolveIVPConfig -from app.simulation.examples.test_mql.system import TestMqlPnl0001LineRhsDiagnostic - -from app.simulation.examples.test_mql.run_full_state_comparison import ( - TestMqlFullStateComparisonExecutionConfig, - TestMqlFullStateComparisonPathConfig, - TestMqlFullStateComparisonScriptConfig, - TestMqlPnl0001EnergyFlowDiagnostic, - TestMqlP4NodeEnthalpyBreakdownDiagnostic, - TestMqlPnl0003EnergyDiagnostic, - TestMqlPneumatic96InletLineDiagnostic, - TestMqlPnch012EnergyEquationDiagnostic, - TestMqlPnvoUpstreamEnthalpyDiagnostic, - TestMqlPnl0001PressureLossCalibrationDiagnostic, - TestMqlPnvoEventBoundaryDiagnostic, - TestMqlPnvoEventWindowCandidateComparisonDiagnostic, - TestMqlPnvoEventWindowCandidateMetricComparison, - TestMqlPnvoEventWindowDiagnostic, - TestMqlPnvoEventWindowSampleDiagnostic, - TestMqlPnvoFlowParameterDiagnostic, - TestMqlPnvoEventWindowSegmentDiagnostic, - format_test_mql_full_state_comparison_summary, - format_test_mql_pnvo_event_boundary_summary, - format_test_mql_pnvo_event_window_candidate_comparison_summary, - format_test_mql_pnvo_event_window_summary, - run_test_mql_full_state_comparison, - _ideal_pn2vol_reference_dtemp, - _series_sample_bracket, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class RunTestMqlFullStateComparisonScriptTests(unittest.TestCase): - def test_ideal_pn2vol_reference_dtemp_uses_internal_energy_mass_offset( - self, - ) -> None: - from app.simulation.examples.test_mql.primitives.pneumatic import HELIUM_PNEUMATIC_GAS - - gas = HELIUM_PNEUMATIC_GAS - result = _ideal_pn2vol_reference_dtemp( - gas=gas, - mass_kg=0.002, - temperature_k=320.0, - mass_derivative_kg_s=0.1, - enthalpy_flow_w=-100.0, - heat_flow_w=5.0, - ) - - expected = ( - -100.0 - + (gas.cp * 298.15 - gas.cv * 320.0) * 0.1 - + 5.0 - ) / (0.002 * gas.cv) - self.assertAlmostEqual(result, expected) - - def test_series_sample_bracket_distinguishes_exact_and_interpolated_times(self) -> None: - times = (0.04000000000000009, 0.050000000000000114) - - exact_left, exact_right = _series_sample_bracket( - times=times, - time_s=0.04, - ) - interpolated_left, interpolated_right = _series_sample_bracket( - times=times, - time_s=0.041, - ) - - self.assertEqual(exact_left, exact_right) - self.assertAlmostEqual(exact_left, 0.04) - self.assertAlmostEqual(interpolated_left, 0.04) - self.assertAlmostEqual(interpolated_right, 0.05) - - def test_full_state_script_writes_summary_and_comparison_csv(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) - run, returned_output_dir = run_test_mql_full_state_comparison( - TestMqlFullStateComparisonScriptConfig( - paths=TestMqlFullStateComparisonPathConfig( - archive_path=TEST_MQL_AME, - output_dir=output_dir, - ), - execution=TestMqlFullStateComparisonExecutionConfig( - data_paths=( - "press@pn_c1_8", - "vol1@pn_brp2_8", - "dm1@pneumatic_69", - "xv@pn_morifice_1", - "dm2@pn_morifice_1", - ), - solver=SolveIVPConfig(t_start=0.0, t_stop=0.0), - t_eval=(0.0,), - ), - ) - ) - summary_path = output_dir / "test_mql_full_state_comparison_summary.txt" - csv_path = output_dir / "test_mql_amesim_comparison.csv" - csv_summary_path = output_dir / "test_mql_amesim_comparison_summary.txt" - summary = summary_path.read_text(encoding="utf-8") - csv_header = csv_path.read_text(encoding="utf-8").splitlines()[0] - csv_summary_exists = csv_summary_path.exists() - - self.assertEqual(returned_output_dir, output_dir) - self.assertEqual(run.sample_count, 1) - self.assertEqual(run.signal_count, 5) - self.assertAlmostEqual(run.comparison.max_abs_error, 0.0, delta=1.0e-8) - self.assertEqual( - run.metrics_by_max_abs_error()[0].data_path, - "press@pn_c1_8", - ) - diagnostic = run.signal_diagnostic("press@pn_c1_8") - self.assertEqual(diagnostic.data_path, "press@pn_c1_8") - self.assertAlmostEqual(diagnostic.initial_python_value, -1300.0) - self.assertAlmostEqual(diagnostic.final_python_value, -1300.0) - self.assertAlmostEqual(diagnostic.final_abs_error, 0.0, delta=1.0e-8) - self.assertEqual( - run.diagnostics_by_final_abs_error()[0].data_path, - "press@pn_c1_8", - ) - flow_diagnostic = run.pnl0001_mass_flow_diagnostic() - self.assertEqual(flow_diagnostic.data_path, "dm1@pneumatic_69") - self.assertAlmostEqual(flow_diagnostic.initial_python_dm1_g_s, 0.0) - self.assertAlmostEqual(flow_diagnostic.initial_amesim_dm1_g_s, 0.0) - self.assertAlmostEqual( - flow_diagnostic.initial_python_canonical_kg_s, - -flow_diagnostic.initial_python_dm1_g_s * 1.0e-3, - ) - pnvo_diagnostic = run.pnvo_diagnostic() - self.assertEqual(pnvo_diagnostic.alias, "pn_morifice_1") - self.assertAlmostEqual(pnvo_diagnostic.initial_python_opening, 0.0) - self.assertAlmostEqual(pnvo_diagnostic.initial_amesim_opening, 0.0) - self.assertAlmostEqual(pnvo_diagnostic.initial_python_mass_flow_kg_s, 0.0) - self.assertAlmostEqual(pnvo_diagnostic.initial_amesim_mass_flow_kg_s, 0.0) - chamber_diagnostic = run.chamber_rhs_diagnostic("pn_c1_8") - self.assertEqual(chamber_diagnostic.chamber_alias, "pn_c1_8") - self.assertEqual(chamber_diagnostic.piston_alias, "pn_brp2_8") - self.assertAlmostEqual(chamber_diagnostic.chamber_volume_m3, 0.015) - self.assertAlmostEqual( - chamber_diagnostic.energy_derivative_w, - chamber_diagnostic.port_a_energy_flow_w - + chamber_diagnostic.port_b_energy_flow_w - + chamber_diagnostic.boundary_work_w - + chamber_diagnostic.thermal_energy_flow_w, - ) - self.assertTrue(csv_summary_exists) - self.assertIn("Mode: Python 132 full-state closure comparison", summary) - self.assertIn("Compared signals: 5", summary) - self.assertIn("AMESim first saved interval: 0.010000000000000023", summary) - self.assertIn("Final time aligns with AMESim sample: True", summary) - self.assertIn("Largest absolute error: press@pn_c1_8=", summary) - self.assertIn("Largest final endpoint error: press@pn_c1_8=", summary) - self.assertIn("Metrics by max absolute error:", summary) - self.assertIn("Endpoint diagnostics by final absolute error:", summary) - self.assertIn( - "PNL0001 canonical mass-flow diagnostic: dm1@pneumatic_69", - summary, - ) - self.assertIn("equals -AMESim dm1 * 1e-3", summary) - self.assertIn("PNVO diagnostic: pn_morifice_1", summary) - self.assertIn("final_python_opening=", summary) - self.assertIn("final_python_mass_flow_kg_s=", summary) - self.assertIn("Largest endpoint chamber RHS breakdown: pn_c1_8", summary) - self.assertIn("piston_alias=pn_brp2_8", summary) - self.assertIn("mass_derivative_kg_s=", summary) - self.assertIn("boundary_work_w=", summary) - self.assertIn("thermal_energy_flow_w=", summary) - self.assertIn("final_python=", summary) - self.assertIn("final_amesim=", summary) - self.assertIn("python.press@pn_c1_8", csv_header) - self.assertIn("amesim.vol1@pn_brp2_8", csv_header) - self.assertIn("python.dm1@pneumatic_69", csv_header) - self.assertIn("python.xv@pn_morifice_1", csv_header) - self.assertIn("python.dm2@pn_morifice_1", csv_header) - - def test_default_execution_aligns_first_amesim_saved_sample(self) -> None: - execution = TestMqlFullStateComparisonExecutionConfig() - - self.assertEqual(execution.solver.t_stop, 0.01) - self.assertEqual(execution.solver.max_step, 1.0e-3) - self.assertEqual(execution.t_eval, (0.0, 0.01)) - self.assertIn("xv@pn_morifice_1", execution.data_paths) - self.assertIn("dm2@pn_morifice_1", execution.data_paths) - - def test_formats_pnvo_event_boundary_diagnostic(self) -> None: - data_paths = ( - "press@pn_c1_8", - "dm1@pneumatic_69", - "xv@pn_morifice_1", - "dm2@pn_morifice_1", - ) - diagnostic = TestMqlPnvoEventBoundaryDiagnostic( - orifice_alias="pn_morifice_1", - event_time_s=0.04, - integration_stop_time_s=0.039999999999999994, - data_paths=data_paths, - python_values_by_data_path={ - "press@pn_c1_8": -1090.9, - "dm1@pneumatic_69": -0.00949, - "xv@pn_morifice_1": 1.0, - "dm2@pn_morifice_1": 502.945, - }, - amesim_values_by_data_path={ - "press@pn_c1_8": -1146.38, - "dm1@pneumatic_69": -0.00667, - "xv@pn_morifice_1": 1.0, - "dm2@pn_morifice_1": 497.824, - }, - ) - - self.assertEqual(diagnostic.orifice_alias, "pn_morifice_1") - self.assertLess(diagnostic.integration_stop_time_s, diagnostic.event_time_s) - self.assertEqual(diagnostic.abs_error("xv@pn_morifice_1"), 0.0) - self.assertAlmostEqual( - diagnostic.abs_error("dm2@pn_morifice_1"), - 5.121, - ) - summary = format_test_mql_pnvo_event_boundary_summary(diagnostic) - self.assertIn("PNVO event boundary diagnostic (pn_morifice_1)", summary) - self.assertIn("Observation side: right-continuous STEP0 opening", summary) - self.assertTrue(summary.endswith("\n")) - - def test_formats_pnvo_event_window_diagnostic_with_samples(self) -> None: - data_paths = ( - "press@pn_c1_8", - "temp@pn_c1_8", - "vol@pn_c1_8", - "mgas1@pn_c1_8", - "p2@pneumatic_69", - "t2@pneumatic_69", - "mgas@pneumatic_69", - "re@pneumatic_69", - "v@pneumatic_69", - "ff@pneumatic_69", - "dm1@pneumatic_69", - "xv@pn_morifice_1", - "dm2@pn_morifice_1", - ) - sample = TestMqlPnvoEventWindowSampleDiagnostic( - time_s=0.05, - data_paths=data_paths, - python_values_by_data_path={ - "press@pn_c1_8": 219127.9, - "temp@pn_c1_8": 372.3, - "vol@pn_c1_8": 15000.0, - "mgas1@pn_c1_8": 5.46, - "p2@pneumatic_69": 93250.0, - "t2@pneumatic_69": 293.15, - "mgas@pneumatic_69": 0.53, - "re@pneumatic_69": 1.41e6, - "v@pneumatic_69": 832.0, - "ff@pneumatic_69": 0.0243, - "dm1@pneumatic_69": 443.2, - "xv@pn_morifice_1": 1.0, - "dm2@pn_morifice_1": 463.7, - }, - amesim_values_by_data_path={ - "press@pn_c1_8": 187060.0, - "temp@pn_c1_8": 296.1, - "vol@pn_c1_8": 15000.0, - "mgas1@pn_c1_8": 5.47, - "p2@pneumatic_69": 1484689.6, - "t2@pneumatic_69": 296.1, - "mgas@pneumatic_69": 0.81, - "re@pneumatic_69": 1.35e6, - "v@pneumatic_69": 519.8, - "ff@pneumatic_69": 0.0241, - "dm1@pneumatic_69": 419.9, - "xv@pn_morifice_1": 1.0, - "dm2@pn_morifice_1": 456.8, - }, - amesim_sample_left_time_s=0.05, - amesim_sample_right_time_s=0.05, - pnl0001_rhs_diagnostics=( - TestMqlPnl0001LineRhsDiagnostic( - line_alias="pneumatic_69", - chamber_alias="pn_c1_8", - line_pressure_pa=1034250.0, - line_temperature_k=293.15, - chamber_pressure_pa=320427.9, - chamber_temperature_k=379.9, - chamber_to_line_flow_kg_s=-0.4432, - node_to_line_flow_kg_s=0.4210, - mass_derivative_kg_s=-0.0222, - port_1_energy_flow_w=-404000.0, - port_2_energy_flow_w=384000.0, - thermal_energy_flow_w=0.0, - energy_derivative_w=-20000.0, - ), - ), - pnl0001_pressure_loss_diagnostics=( - TestMqlPnl0001PressureLossCalibrationDiagnostic( - line_alias="pneumatic_69", - chamber_alias="pn_c1_8", - time_s=0.05, - amesim_cm=0.0159, - amesim_dm1_g_s=419.9, - amesim_mass_flow_magnitude_kg_s=0.4199, - amesim_line_gauge_pressure_pa=1484689.6, - amesim_chamber_gauge_pressure_pa=187060.0, - amesim_line_temperature_k=296.1, - amesim_pressure_drop_pa=1297629.6, - current_darcy_pressure_drop_pa=422633.2, - pressure_drop_multiplier=3.07, - candidate_pn2pipefr_dm1_g_s=424.2, - candidate_pn2pipefr_to_amesim_dm1_ratio=1.01, - amesim_linear_conductance_kg_s_sqrt_k_per_pa=5.56e-6, - python_linear_conductance_kg_s_sqrt_k_per_pa=1.06e-5, - python_to_amesim_linear_conductance_ratio=1.91, - ), - ), - pnvo_flow_parameter_diagnostics=( - TestMqlPnvoFlowParameterDiagnostic( - orifice_alias="pn_morifice_1", - time_s=0.05, - amesim_cm=0.0158, - amesim_dm2_g_s=456.8, - amesim_opening=1.0, - amesim_gas_velocity_m_s=885.9, - python_opening=1.0, - python_flow_coefficient=0.45, - python_effective_area_m2=78.5e-6, - python_line_pressure_pa=15310000.0, - python_boundary_pressure_pa=1586000.0, - python_upstream_pressure_pa=15310000.0, - python_upstream_temperature_k=293.15, - python_mass_flow_kg_s=0.4637, - python_cm=0.0147, - python_to_amesim_cm_ratio=0.93, - ), - ), - pnvo_upstream_enthalpy_diagnostics=( - TestMqlPnvoUpstreamEnthalpyDiagnostic( - orifice_alias="pn_morifice_1", - upstream_line_alias="pneumatic_87", - time_s=0.05, - amesim_orifice_port_2_enthalpy_flow_w=-18851.7, - amesim_orifice_port_2_mass_flow_g_s=456.8, - amesim_orifice_port_3_enthalpy_flow_w=18851.7, - amesim_orifice_port_3_mass_flow_g_s=-456.8, - amesim_upstream_line_center_enthalpy_flow_w=15823.7, - amesim_upstream_line_center_mass_flow_g_s=-454.2, - amesim_upstream_line_port_2_temperature_k=287.7, - amesim_upstream_line_port_2_pressure_pa=13.871e6, - amesim_orifice_port_2_implied_reference_temperature_k=290.2, - amesim_orifice_port_2_implied_temperature_delta_to_line_k=2.5, - amesim_upstream_line_port_2_reference_enthalpy_flow_w=-24714.8, - amesim_upstream_line_port_2_reference_enthalpy_error_w=-5863.1, - amesim_upstream_line_port_2_real_gas_reference_enthalpy_flow_w=-19240.8, - amesim_upstream_line_port_2_real_gas_reference_enthalpy_error_w=-389.1, - python_orifice_to_node_flow_kg_s=0.4637, - python_upstream_line_port_2_temperature_k=282.2, - python_upstream_line_port_2_pressure_pa=13.84e6, - python_current_port_2_enthalpy_flow_w=679490.8, - python_reference_port_2_enthalpy_flow_w=-38478.5, - python_real_gas_reference_port_2_enthalpy_flow_w=-32998.5, - python_current_port_2_enthalpy_error_w=698342.5, - python_reference_port_2_enthalpy_error_w=-19626.8, - python_real_gas_reference_port_2_enthalpy_error_w=-14146.8, - ), - ), - pnl0003_energy_diagnostics=( - TestMqlPnl0003EnergyDiagnostic( - line_alias="pneumatic_87", - time_s=0.05, - amesim_port_1_temperature_k=293.1, - amesim_port_2_temperature_k=287.7, - amesim_port_1_pressure_pa=15.2e6, - amesim_port_2_pressure_pa=13.9e6, - amesim_port_1_mass_flow_g_s=-8.5, - amesim_port_1_enthalpy_flow_w=85.0, - amesim_center_mass_flow_g_s=-454.2, - amesim_center_enthalpy_flow_w=15823.7, - amesim_port_2_mass_flow_g_s=-456.8, - amesim_port_2_enthalpy_flow_w=18851.7, - amesim_port_1_storage_mass_derivative_g_s=-462.7, - amesim_port_1_storage_enthalpy_sum_w=15908.7, - amesim_port_2_storage_mass_derivative_g_s=-2.6, - amesim_port_2_storage_enthalpy_sum_w=3028.0, - python_pn3_node_port_1_mass_flow_g_s=-333.0, - python_pn3_node_port_3_mass_flow_g_s=-119.9, - python_pn3_node_to_line_mass_flow_g_s=452.9, - python_pn3_node_to_line_mass_flow_error_g_s=461.4, - amesim_port_1_mass_estimate_g=1.12, - amesim_port_2_mass_estimate_g=1.05, - python_port_1_mass_g=1.11, - python_port_2_mass_g=1.04, - python_port_1_mass_error_to_amesim_g=-0.01, - python_port_2_mass_error_to_amesim_g=-0.01, - amesim_total_mass_derivative_fd_g_s=-470.0, - amesim_total_mass_derivative_backward_g_s=-540.0, - amesim_total_mass_derivative_forward_g_s=-400.0, - amesim_total_storage_mass_derivative_g_s=-465.3, - amesim_total_storage_mass_derivative_residual_g_s=4.7, - amesim_center_flow_python_sign_equivalent_g_s=454.2, - amesim_center_flow_python_sign_error_g_s=-0.2, - python_center_mass_flow_g_s=454.0, - amesim_port_1_center_mass_derivative_g_s=-454.2, - amesim_port_2_center_mass_derivative_g_s=454.2, - python_port_1_external_mass_flow_g_s=337.0, - python_port_1_external_mass_flow_error_g_s=15.1, - python_port_1_center_mass_derivative_g_s=-454.0, - python_port_1_center_mass_derivative_error_g_s=0.2, - python_port_1_storage_mass_derivative_g_s=-117.0, - python_port_1_storage_mass_derivative_error_g_s=345.7, - python_port_2_external_mass_flow_g_s=-456.4, - python_port_2_external_mass_flow_error_g_s=0.4, - python_port_2_center_mass_derivative_g_s=454.0, - python_port_2_center_mass_derivative_error_g_s=-0.2, - python_port_2_storage_mass_derivative_g_s=-2.4, - python_port_2_storage_mass_derivative_error_g_s=0.2, - python_amesim_state_darcy_center_mass_flow_g_s=453.5, - python_amesim_state_darcy_center_mass_flow_error_g_s=-0.7, - python_amesim_state_pn2pipefr_center_mass_flow_g_s=455.0, - python_amesim_state_pn2pipefr_center_mass_flow_error_g_s=0.8, - amesim_center_real_gas_reference_port_1_estimate_w=15700.0, - amesim_center_real_gas_reference_port_1_error_w=-123.7, - python_center_ideal_reference_port_1_estimate_w=16100.0, - python_center_ideal_reference_port_1_error_w=276.3, - python_center_ideal_reference_port_2_estimate_w=16200.0, - python_center_ideal_reference_port_2_error_w=376.3, - python_center_real_gas_reference_port_1_estimate_w=8300.0, - python_center_real_gas_reference_port_1_error_w=-7523.7, - python_center_real_gas_reference_port_2_estimate_w=8400.0, - python_center_real_gas_reference_port_2_error_w=-7423.7, - python_total_mass_derivative_kg_s=-0.47, - python_port_1_reference_external_enthalpy_flow_w=80.0, - python_port_1_reference_center_enthalpy_flow_w=15800.0, - python_port_1_reference_storage_enthalpy_sum_w=15880.0, - python_port_1_reference_storage_enthalpy_error_w=-28.7, - python_port_2_reference_external_enthalpy_flow_w=19000.0, - python_port_2_reference_center_enthalpy_flow_w=-15800.0, - python_port_2_reference_storage_enthalpy_sum_w=3200.0, - python_port_2_reference_storage_enthalpy_error_w=172.0, - amesim_port_1_external_enthalpy_dtemp_component_k_s=80.0, - amesim_port_1_center_enthalpy_dtemp_component_k_s=14000.0, - amesim_port_1_mass_offset_dtemp_component_k_s=-13200.0, - amesim_port_1_heat_dtemp_component_k_s=0.0, - python_port_1_external_enthalpy_dtemp_component_k_s=75.0, - python_port_1_center_enthalpy_dtemp_component_k_s=13900.0, - python_port_1_mass_offset_dtemp_component_k_s=-13100.0, - python_port_1_heat_dtemp_component_k_s=0.0, - amesim_port_2_external_enthalpy_dtemp_component_k_s=6000.0, - amesim_port_2_center_enthalpy_dtemp_component_k_s=-5000.0, - amesim_port_2_mass_offset_dtemp_component_k_s=-1500.0, - amesim_port_2_heat_dtemp_component_k_s=0.0, - python_port_2_external_enthalpy_dtemp_component_k_s=6100.0, - python_port_2_center_enthalpy_dtemp_component_k_s=-5100.0, - python_port_2_mass_offset_dtemp_component_k_s=-1650.0, - python_port_2_heat_dtemp_component_k_s=0.0, - amesim_port_1_temperature_derivative_fd_k_s=-210.0, - amesim_port_1_temperature_derivative_backward_k_s=-420.0, - amesim_port_1_temperature_derivative_forward_k_s=100.0, - amesim_port_2_temperature_derivative_fd_k_s=-540.0, - amesim_port_2_temperature_derivative_backward_k_s=-600.0, - amesim_port_2_temperature_derivative_forward_k_s=120.0, - amesim_port_1_pn2vol_dtemp_k_s=-220.0, - amesim_port_2_pn2vol_dtemp_k_s=-530.0, - amesim_port_1_pn2vol_dtemp_residual_k_s=-10.0, - amesim_port_2_pn2vol_dtemp_residual_k_s=10.0, - amesim_port_1_pressure_derivative_fd_pa_s=-2100000.0, - amesim_port_2_pressure_derivative_fd_pa_s=-5400000.0, - amesim_port_1_pressure_derivative_mass_component_pa_s=-3100000.0, - amesim_port_1_pressure_derivative_temperature_component_pa_s=900000.0, - amesim_port_1_pressure_derivative_eos_sum_pa_s=-2200000.0, - amesim_port_1_pressure_derivative_eos_residual_pa_s=-100000.0, - amesim_port_2_pressure_derivative_mass_component_pa_s=-6100000.0, - amesim_port_2_pressure_derivative_temperature_component_pa_s=800000.0, - amesim_port_2_pressure_derivative_eos_sum_pa_s=-5300000.0, - amesim_port_2_pressure_derivative_eos_residual_pa_s=100000.0, - python_port_1_pressure_derivative_mass_component_pa_s=-3000000.0, - python_port_1_pressure_derivative_temperature_component_pa_s=950000.0, - python_port_1_pressure_derivative_eos_sum_pa_s=-2050000.0, - python_port_2_pressure_derivative_mass_component_pa_s=-6000000.0, - python_port_2_pressure_derivative_temperature_component_pa_s=850000.0, - python_port_2_pressure_derivative_eos_sum_pa_s=-5150000.0, - python_port_1_temperature_k=293.15, - python_port_2_temperature_k=282.2, - python_port_1_pressure_pa=15.3e6, - python_port_2_pressure_pa=13.8e6, - python_port_1_mass_derivative_kg_s=-0.46, - python_port_2_mass_derivative_kg_s=-0.01, - python_port_1_current_dtemp_k_s=0.0, - python_port_2_current_dtemp_k_s=-1200.0, - python_port_1_real_gas_reference_dtemp_k_s=-100.0, - python_port_2_real_gas_reference_dtemp_k_s=-650.0, - python_port_2_amesim_center_counterfactual_dtemp_k_s=-610.0, - python_port_2_amesim_external_counterfactual_dtemp_k_s=-620.0, - python_port_2_amesim_external_center_counterfactual_dtemp_k_s=-630.0, - python_port_2_temperature_error_to_amesim_k=-5.5, - ), - ), - pneumatic_96_inlet_line_diagnostics=( - TestMqlPneumatic96InletLineDiagnostic( - line_alias="pneumatic_96", - node_alias="pn_node3_8", - orifice_alias="pn_orifice_18", - chamber_alias="pn_general_chamber", - time_s=0.05, - amesim_line_pressure_pa=14256386.8, - amesim_line_temperature_k=289.8, - amesim_line_mass_g=3.51, - amesim_line_mass_derivative_fd_g_s=-6.0, - amesim_line_to_node_mass_flow_g_s=452.2, - amesim_orifice_to_line_mass_flow_g_s=446.2, - amesim_storage_mass_derivative_from_ports_g_s=-6.0, - amesim_storage_mass_derivative_residual_g_s=0.0, - amesim_chamber_pressure_pa=15131788.1, - amesim_chamber_temperature_k=291.9, - amesim_node_pressure_pa=13887929.6, - amesim_node_temperature_k=288.9, - amesim_line_temperature_derivative_fd_k_s=149.5, - amesim_line_pressure_derivative_fd_pa_s=-17835932.3, - amesim_port_1_enthalpy_dtemp_component_k_s=1234.0, - amesim_port_2_enthalpy_dtemp_component_k_s=-724.0, - amesim_mass_offset_dtemp_component_k_s=-351.0, - amesim_reference_temperature_derivative_sum_k_s=159.0, - amesim_reference_temperature_derivative_residual_k_s=9.5, - amesim_pressure_derivative_mass_component_pa_s=-30562435.9, - amesim_pressure_derivative_temperature_component_pa_s=12794597.7, - amesim_pressure_derivative_eos_sum_pa_s=-17767838.2, - amesim_pressure_derivative_eos_residual_pa_s=68094.1, - python_line_pressure_pa=14257000.0, - python_line_temperature_k=290.1, - python_line_mass_g=3.52, - python_line_pressure_error_pa=613.2, - python_line_temperature_error_k=0.3, - python_line_mass_error_g=0.01, - python_line_to_node_mass_flow_g_s=453.0, - python_line_to_node_mass_flow_error_g_s=0.8, - python_orifice_to_line_mass_flow_g_s=447.0, - python_orifice_to_line_mass_flow_error_g_s=0.8, - python_storage_mass_derivative_g_s=-6.0, - python_storage_mass_derivative_error_g_s=0.0, - python_line_to_node_amesim_node_mass_flow_g_s=451.0, - python_line_to_node_amesim_node_mass_flow_error_g_s=-1.2, - python_line_to_node_amesim_line_mass_flow_g_s=452.8, - python_line_to_node_amesim_line_mass_flow_error_g_s=0.6, - python_line_to_node_amesim_node_line_mass_flow_g_s=452.0, - python_line_to_node_amesim_node_line_mass_flow_error_g_s=-0.2, - python_orifice_to_line_amesim_line_mass_flow_g_s=445.8, - python_orifice_to_line_amesim_line_mass_flow_error_g_s=-0.4, - python_orifice_to_line_amesim_chamber_mass_flow_g_s=446.6, - python_orifice_to_line_amesim_chamber_mass_flow_error_g_s=0.4, - python_orifice_to_line_amesim_line_chamber_mass_flow_g_s=446.0, - python_orifice_to_line_amesim_line_chamber_mass_flow_error_g_s=-0.2, - python_storage_amesim_boundary_states_mass_derivative_g_s=-6.0, - python_storage_amesim_boundary_states_mass_derivative_error_g_s=0.0, - python_chamber_pressure_pa=15131800.0, - python_chamber_temperature_k=292.0, - python_current_temperature_derivative_k_s=261.9, - python_port_1_current_dtemp_component_k_s=0.0, - python_port_2_current_dtemp_component_k_s=261.9, - python_heat_dtemp_component_k_s=0.0, - python_temperature_derivative_error_k_s=112.4, - python_pressure_derivative_mass_component_pa_s=-17917501.3, - python_pressure_derivative_temperature_component_pa_s=650331.8, - python_pressure_derivative_eos_sum_pa_s=-17267169.5, - python_pressure_derivative_eos_error_pa_s=568762.8, - python_reference_port_1_enthalpy_flow_w=0.0, - python_reference_port_2_enthalpy_flow_w=-710.0, - python_reference_port_1_dtemp_component_k_s=0.0, - python_reference_port_2_dtemp_component_k_s=-65.3, - python_reference_mass_offset_dtemp_component_k_s=-382.2, - python_reference_temperature_derivative_k_s=-447.5, - python_reference_temperature_derivative_error_k_s=-597.0, - python_reference_pressure_derivative_mass_component_pa_s=( - -17917501.3 - ), - python_reference_pressure_derivative_temperature_component_pa_s=( - -22045000.0 - ), - python_reference_pressure_derivative_eos_sum_pa_s=-39962501.3, - python_reference_pressure_derivative_eos_error_pa_s=( - -21955100.0 - ), - python_reference_amesim_storage_mass_offset_dtemp_component_k_s=( - -351.0 - ), - python_reference_amesim_storage_temperature_derivative_k_s=( - -416.3 - ), - python_reference_amesim_storage_temperature_derivative_error_k_s=( - -565.8 - ), - python_reference_amesim_storage_pressure_derivative_mass_component_pa_s=( - -25407051.3 - ), - python_reference_amesim_storage_pressure_derivative_temperature_component_pa_s=( - -20500000.0 - ), - python_reference_amesim_storage_pressure_derivative_eos_sum_pa_s=( - -45907051.3 - ), - python_reference_amesim_storage_pressure_derivative_eos_error_pa_s=( - -27999648.2 - ), - python_active_temperature_derivative_k_s=261.9, - python_active_temperature_derivative_error_k_s=112.4, - python_active_pressure_derivative_eos_sum_pa_s=-17267169.5, - python_active_pressure_derivative_eos_error_pa_s=568762.8, - python_chamber_pressure_error_pa=11.9, - python_chamber_temperature_error_k=0.1, - ), - ), - pnl0001_energy_flow_diagnostics=( - TestMqlPnl0001EnergyFlowDiagnostic( - line_alias="pneumatic_69", - chamber_alias="pn_c1_8", - node_alias="pnnode4_16", - time_s=0.05, - amesim_port_1_enthalpy_flow_w=-4248.6, - amesim_port_1_mass_flow_g_s=419.9, - amesim_node_port_2_enthalpy_flow_w=-18664.2, - amesim_node_port_2_mass_flow_g_s=438.3, - amesim_observed_port_enthalpy_sum_w=-22912.8, - python_port_1_internal_energy_flow_w=-378000.0, - python_port_2_internal_energy_flow_w=398000.0, - python_current_energy_derivative_w=20000.0, - python_direct_enthalpy_port_1_flow_w=-629000.0, - python_direct_enthalpy_port_2_flow_w=663000.0, - python_direct_enthalpy_energy_derivative_w=34000.0, - python_direct_minus_current_energy_derivative_w=14000.0, - python_p4_node_port_2_enthalpy_flow_w=640000.0, - python_p4_node_port_2_mass_flow_g_s=430.0, - python_p4_node_port_2_connected_h_j_kg=1488372.1, - python_p4_node_port_2_connected_temperature_k=286.6, - python_p4_node_port_2_connected_h_delta_to_line_h_j_kg=-34000.0, - python_p4_node_port_2_counterfactual_dtemp_k_s=-5100.0, - reference_temperature_k=298.15, - amesim_port_1_reference_enthalpy_estimate_w=-4467.0, - amesim_port_1_reference_enthalpy_error_w=-218.4, - amesim_candidate_pn2pipefr_dm2i_g_s=-419.9, - amesim_candidate_pn2pipefr_dh2i_w=4467.0, - amesim_candidate_storage_enthalpy_sum_w=-14197.2, - amesim_candidate_pn2pipefr_dtemp_k_s=-5480.0, - amesim_candidate_pn2pipefr_dtemp_residual_k_s=-5420.3, - python_reference_port_1_enthalpy_flow_w=-10760.0, - python_reference_node_port_2_enthalpy_flow_w=-18500.0, - python_reference_observed_port_enthalpy_sum_w=-29260.0, - python_reference_port_1_to_amesim_error_w=-6511.4, - python_reference_node_port_2_to_amesim_error_w=164.2, - python_reference_sum_to_amesim_error_w=-6347.2, - python_current_line_temperature_derivative_k_s=0.0, - amesim_line_temperature_derivative_fd_k_s=277.3, - amesim_pn2vol2_dtemp_node_plus_dh1_k_s=-9000.0, - amesim_pn2vol2_dtemp_node_minus_dh1_k_s=-5600.0, - python_reference_pn2vol2_dtemp_node_minus_dh1_k_s=-11200.0, - ), - ), - p4_node_enthalpy_breakdown_diagnostics=( - TestMqlP4NodeEnthalpyBreakdownDiagnostic( - node_alias="pnnode4_16", - time_s=0.05, - amesim_port_1_enthalpy_flow_w=-1000.0, - amesim_port_1_mass_flow_g_s=-30.0, - amesim_port_3_enthalpy_flow_w=-2000.0, - amesim_port_3_mass_flow_g_s=-40.0, - amesim_port_4_enthalpy_flow_w=-15664.2, - amesim_port_4_mass_flow_g_s=508.3, - amesim_port_2_enthalpy_flow_w=-18664.2, - amesim_port_2_mass_flow_g_s=438.3, - python_port_1_enthalpy_flow_w=-900.0, - python_port_1_mass_flow_g_s=-29.0, - python_port_3_enthalpy_flow_w=-2100.0, - python_port_3_mass_flow_g_s=-41.0, - python_port_4_enthalpy_flow_w=643000.0, - python_port_4_mass_flow_g_s=500.0, - python_port_2_enthalpy_flow_w=640000.0, - python_port_2_mass_flow_g_s=430.0, - python_reference_port_1_enthalpy_flow_w=-800.0, - python_reference_port_3_enthalpy_flow_w=-1700.0, - python_reference_port_4_enthalpy_flow_w=-16000.0, - python_reference_port_2_enthalpy_flow_w=-18500.0, - python_real_gas_reference_port_1_enthalpy_flow_w=-790.0, - python_real_gas_reference_port_3_enthalpy_flow_w=-1685.0, - python_real_gas_reference_port_4_enthalpy_flow_w=-15750.0, - python_real_gas_reference_port_2_enthalpy_flow_w=-18225.0, - python_port_2_enthalpy_error_w=658664.2, - python_reference_port_2_enthalpy_error_w=164.2, - python_real_gas_reference_port_2_enthalpy_error_w=439.2, - ), - ), - pnch012_energy_equation_diagnostics=( - TestMqlPnch012EnergyEquationDiagnostic( - chamber_alias="pn_c1_8", - piston_alias="pn_brp2_8", - line_alias="pneumatic_69", - time_s=0.05, - amesim_port_1_enthalpy_flow_w=-4248.6, - amesim_port_1_mass_flow_g_s=419.9, - amesim_chamber_mass_derivative_fd_g_s=370.8, - amesim_chamber_mass_derivative_residual_g_s=49.1, - amesim_chamber_temperature_derivative_fd_k_s=5134.6, - amesim_chamber_volume_rate_fd_m3_s=0.001004, - amesim_piston_volume_rate_m3_s=2.5e-8, - amesim_heat_flow_w=-0.0, - amesim_boundary_work_fd_volume_w=-289.5, - amesim_pn2vol_reference_dtemp_fd_volume_k_s=8090.0, - amesim_pn2vol_reference_dtemp_fd_volume_residual_k_s=2955.4, - amesim_pn2vol_reference_dtemp_piston_volume_k_s=8107.0, - amesim_pn2vol_reference_dtemp_piston_volume_residual_k_s=2972.4, - python_port_a_mass_flow_kg_s=-0.42, - python_current_energy_derivative_w=-28000.0, - python_current_chamber_temperature_derivative_k_s=-7800.0, - reference_temperature_k=298.15, - ), - ), - ) - diagnostic = TestMqlPnvoEventWindowDiagnostic( - orifice_alias="pn_morifice_1", - event_time_s=0.04, - final_time_s=0.05, - data_paths=data_paths, - segment_diagnostics=( - TestMqlPnvoEventWindowSegmentDiagnostic( - t_start=0.048, - t_stop=0.05, - method="BDF", - rtol=1.0e-5, - atol=1.0e-8, - max_step=1.0e-5, - rhs_evaluations=8154, - success=True, - message="success", - ), - ), - sample_diagnostics=(sample,), - python_values_by_data_path=sample.python_values_by_data_path, - amesim_values_by_data_path=sample.amesim_values_by_data_path, - ) - - summary = format_test_mql_pnvo_event_window_summary(diagnostic) - - self.assertAlmostEqual(diagnostic.abs_error("dm2@pn_morifice_1"), 6.9) - self.assertAlmostEqual(sample.abs_error("press@pn_c1_8"), 32067.9) - self.assertIn("PNVO event window diagnostic (pn_morifice_1)", summary) - self.assertIn("Sample comparisons:", summary) - self.assertFalse(sample.amesim_values_are_interpolated) - self.assertIn("t=0.05 (amesim=exact@0.05)", summary) - self.assertIn("rhs=8154", summary) - self.assertIn("rhs@pneumatic_69", summary) - self.assertIn("dm_dt=-0.0222", summary) - self.assertIn("pressure_loss@pneumatic_69", summary) - self.assertIn("cm=0.0159", summary) - self.assertIn("dp_multiplier=3.07", summary) - self.assertIn("candidate_pn2pipefr_dm1=424.2", summary) - self.assertIn("candidate_pn2pipefr_ratio=1.01", summary) - self.assertIn("amesim_linear_k=5.56e-06", summary) - self.assertIn("python_linear_k=1.06e-05", summary) - self.assertIn("linear_k_ratio=1.91", summary) - self.assertIn("flow_parameter@pn_morifice_1", summary) - self.assertIn("amesim_cm=0.0158", summary) - self.assertIn("python_cm=0.0147", summary) - self.assertIn("cm_ratio=0.93", summary) - self.assertIn("amesim_gasvel=885.9", summary) - self.assertIn("pnvo_upstream_enthalpy@pn_morifice_1", summary) - self.assertIn("amesim_dh2=-18851.7", summary) - self.assertIn("amesim_line_dhctr=15823.7", summary) - self.assertIn("amesim_implied_t2=290.2", summary) - self.assertIn("amesim_implied_t2_delta=2.5", summary) - self.assertIn("amesim_ref_dh2=-24714.8", summary) - self.assertIn("amesim_real_ref_dh2=-19240.8", summary) - self.assertIn("amesim_real_ref_dh2_error=-389.1", summary) - self.assertIn("python_current_dh2=679490.8", summary) - self.assertIn("python_ref_dh2=-38478.5", summary) - self.assertIn("python_real_ref_dh2=-32998.5", summary) - self.assertIn("inlet_line@pneumatic_96", summary) - self.assertIn("node=pn_node3_8", summary) - self.assertIn("orifice=pn_orifice_18", summary) - self.assertIn("amesim_line_to_node_dm1=452.2", summary) - self.assertIn("amesim_orifice_to_line_dm2=446.2", summary) - self.assertIn("python_line_to_node_dm=453.0", summary) - self.assertIn("python_line_to_node_dm_error=0.8", summary) - self.assertIn("python_storage_dm_error=0.0", summary) - self.assertIn("python_line_to_node_amesim_node_line_dm=452.0", summary) - self.assertIn("python_orifice_to_line_amesim_line_chamber_dm=446.0", summary) - self.assertIn("python_storage_amesim_boundary_states_dm_error=0.0", summary) - self.assertIn("amesim_fd_dT=149.5", summary) - self.assertIn("amesim_dT_m_offset=-351.0", summary) - self.assertIn("python_current_dT=261.9", summary) - self.assertIn("python_dT_error=112.4", summary) - self.assertIn("python_dP_error=568762.8", summary) - self.assertIn("python_ref_dT=-447.5", summary) - self.assertIn("python_ref_dT_error=-597.0", summary) - self.assertIn("python_ref_dP_error=-21955100.0", summary) - self.assertIn("python_ref_amesim_storage_dT=-416.3", summary) - self.assertIn("python_ref_amesim_storage_dP_error=-27999648.2", summary) - self.assertIn("python_active_dT=261.9", summary) - self.assertIn("python_active_dP_error=568762.8", summary) - self.assertIn("pnl0003_energy@pneumatic_87", summary) - self.assertIn("amesim_sdm1=-462.7", summary) - self.assertIn("amesim_sdh2=3028.0", summary) - self.assertIn("python_node_dm1=-333.0", summary) - self.assertIn("python_node_dm3=-119.9", summary) - self.assertIn("python_node_to_line_dm=452.9", summary) - self.assertIn("python_node_to_line_dm_error=461.4", summary) - self.assertIn("amesim_m2=1.05", summary) - self.assertIn("python_m2=1.04", summary) - self.assertIn("python_m2_error=-0.01", summary) - self.assertIn("amesim_fd_dmgas=-470.0", summary) - self.assertIn("amesim_back_dmgas=-540.0", summary) - self.assertIn("amesim_fwd_dmgas=-400.0", summary) - self.assertIn("amesim_storage_dmgas=-465.3", summary) - self.assertIn("amesim_storage_dmgas_residual=4.7", summary) - self.assertIn("amesim_dmctr_py_sign=454.2", summary) - self.assertIn("python_center_dm=454.0", summary) - self.assertIn("amesim_sdm1_center=-454.2", summary) - self.assertIn("amesim_sdm2_center=454.2", summary) - self.assertIn("python_sdm1_ext=337.0", summary) - self.assertIn("python_sdm1_ext_error=15.1", summary) - self.assertIn("python_sdm1_center=-454.0", summary) - self.assertIn("python_sdm1_center_error=0.2", summary) - self.assertIn("python_sdm1=-117.0", summary) - self.assertIn("python_sdm1_error=345.7", summary) - self.assertIn("python_sdm2_ext=-456.4", summary) - self.assertIn("python_sdm2_ext_error=0.4", summary) - self.assertIn("python_sdm2_center=454.0", summary) - self.assertIn("python_sdm2_center_error=-0.2", summary) - self.assertIn("python_sdm2=-2.4", summary) - self.assertIn("python_sdm2_error=0.2", summary) - self.assertIn("python_amesim_state_darcy_center_dm=453.5", summary) - self.assertIn("python_amesim_state_darcy_center_dm_error=-0.7", summary) - self.assertIn("python_amesim_state_pn2pipefr_center_dm=455.0", summary) - self.assertIn("python_amesim_state_pn2pipefr_center_dm_error=0.8", summary) - self.assertIn("amesim_center_real_p1_error=-123.7", summary) - self.assertIn("python_center_ideal_p2=16200.0", summary) - self.assertIn("python_center_dm_error=-0.2", summary) - self.assertIn("python_dmgas_dt=-0.47", summary) - self.assertIn("python_ref_sdh1=15880.0", summary) - self.assertIn("python_ref_sdh2_error=172.0", summary) - self.assertIn("amesim_dT1_ext_h=80.0", summary) - self.assertIn("amesim_dT1_ctr_h=14000.0", summary) - self.assertIn("amesim_dT1_m_offset=-13200.0", summary) - self.assertIn("python_dT1_ext_h=75.0", summary) - self.assertIn("python_dT1_ctr_h=13900.0", summary) - self.assertIn("python_dT1_m_offset=-13100.0", summary) - self.assertIn("amesim_dT2_ext_h=6000.0", summary) - self.assertIn("amesim_dT2_ctr_h=-5000.0", summary) - self.assertIn("amesim_dT2_m_offset=-1500.0", summary) - self.assertIn("python_dT2_ext_h=6100.0", summary) - self.assertIn("python_dT2_ctr_h=-5100.0", summary) - self.assertIn("python_dT2_m_offset=-1650.0", summary) - self.assertIn("amesim_back_dT2=-600.0", summary) - self.assertIn("amesim_fwd_dT2=120.0", summary) - self.assertIn("amesim_pn2vol_dT2=-530.0", summary) - self.assertIn("amesim_fd_dP1=-2100000.0", summary) - self.assertIn("amesim_dP1_m=-3100000.0", summary) - self.assertIn("amesim_dP1_T=900000.0", summary) - self.assertIn("amesim_dP1_residual=-100000.0", summary) - self.assertIn("amesim_dP2_sum=-5300000.0", summary) - self.assertIn("python_dP1_sum=-2050000.0", summary) - self.assertIn("python_dP2_T=850000.0", summary) - self.assertIn("python_real_ref_dT2=-650.0", summary) - self.assertIn("python_amesim_center_dT2=-610.0", summary) - self.assertIn("python_amesim_ext_dT2=-620.0", summary) - self.assertIn("python_amesim_ext_ctr_dT2=-630.0", summary) - self.assertIn("python_t2_error=-5.5", summary) - self.assertIn("energy_flow@pneumatic_69", summary) - self.assertIn("amesim_dh1=-4248.6", summary) - self.assertIn("amesim_node_dh2=-18664.2", summary) - self.assertIn("direct_h_dU_dt=34000.0", summary) - self.assertIn("python_p4_node_dh2=640000.0", summary) - self.assertIn("python_p4_node_dm2=430.0", summary) - self.assertIn("python_p4_node_h2=1488372.1", summary) - self.assertIn("python_p4_node_t2=286.6", summary) - self.assertIn("python_p4_node_h2_delta=-34000.0", summary) - self.assertIn("python_p4_node_counterfactual_dT=-5100.0", summary) - self.assertIn("ref_dh1_est=-4467.0", summary) - self.assertIn("ref_dh1_error=-218.4", summary) - self.assertIn("candidate_dm2i=-419.9", summary) - self.assertIn("candidate_dh2i=4467.0", summary) - self.assertIn("candidate_storage_sdh=-14197.2", summary) - self.assertIn("candidate_pn2pipefr_dT=-5480.0", summary) - self.assertIn("python_ref_dh1=-10760.0", summary) - self.assertIn("python_ref_node_dh2=-18500.0", summary) - self.assertIn("python_ref_sdh=-29260.0", summary) - self.assertIn("python_ref_sdh_error=-6347.2", summary) - self.assertIn("python_current_dT=0.0", summary) - self.assertIn("amesim_fd_dT=277.3", summary) - self.assertIn("amesim_pn2vol2_dT_node_plus_dh1=-9000.0", summary) - self.assertIn("python_ref_pn2vol2_dT_node_minus_dh1=-11200.0", summary) - self.assertIn("p4_node_enthalpy@pnnode4_16", summary) - self.assertIn("python_real_ref_p2_dh_error=439.2", summary) - self.assertIn("amesim_p4_dh=-15664.2", summary) - self.assertIn("python_p4_dh=643000.0", summary) - self.assertIn("python_ref_p2_dh=-18500.0", summary) - self.assertIn("python_ref_p2_dh_error=164.2", summary) - self.assertIn("pnch012_energy@pn_c1_8", summary) - self.assertIn("amesim_fd_dmgas=370.8", summary) - self.assertIn("amesim_dmgas_residual=49.1", summary) - self.assertIn("amesim_fd_dvol=0.001004", summary) - self.assertIn("amesim_pn2vol_ref_dT_fdvol=8090.0", summary) - self.assertIn("python_current_dT=-7800.0", summary) - self.assertIn("Final comparison:", summary) - self.assertTrue(summary.endswith("\n")) - - def test_formats_pnvo_event_window_candidate_comparison(self) -> None: - data_paths = ("press@pn_c1_8", "dm2@pn_morifice_1") - default_diagnostic = TestMqlPnvoEventWindowDiagnostic( - orifice_alias="pn_morifice_1", - event_time_s=0.04, - final_time_s=0.05, - data_paths=data_paths, - segment_diagnostics=(), - sample_diagnostics=(), - python_values_by_data_path={ - "press@pn_c1_8": 1.0, - "dm2@pn_morifice_1": 10.0, - }, - amesim_values_by_data_path={ - "press@pn_c1_8": 4.0, - "dm2@pn_morifice_1": 8.0, - }, - ) - candidate_diagnostic = TestMqlPnvoEventWindowDiagnostic( - orifice_alias="pn_morifice_1", - event_time_s=0.04, - final_time_s=0.05, - data_paths=data_paths, - segment_diagnostics=(), - sample_diagnostics=(), - python_values_by_data_path={ - "press@pn_c1_8": 3.0, - "dm2@pn_morifice_1": 13.0, - }, - amesim_values_by_data_path={ - "press@pn_c1_8": 4.0, - "dm2@pn_morifice_1": 8.0, - }, - ) - metric = TestMqlPnvoEventWindowCandidateMetricComparison( - time_s=0.0401, - metric_name="p96:p2_abs_error_pa", - default_abs_error=32000.0, - candidate_abs_error=3600.0, - ) - diagnostic = TestMqlPnvoEventWindowCandidateComparisonDiagnostic( - orifice_alias="pn_morifice_1", - event_time_s=0.04, - final_time_s=0.05, - default_diagnostic=default_diagnostic, - candidate_diagnostic=candidate_diagnostic, - metric_comparisons=(metric,), - ) - - summary = format_test_mql_pnvo_event_window_candidate_comparison_summary( - diagnostic - ) - - self.assertEqual(metric.abs_error_delta, -28400.0) - self.assertIn("PNVO event window candidate comparison", summary) - self.assertIn("negative delta means the candidate reduced error", summary) - self.assertIn("t=0.0401 - p96:p2_abs_error_pa", summary) - self.assertIn("default_abs_error=32000.0", summary) - self.assertIn("candidate_abs_error=3600.0", summary) - self.assertIn("delta=-28400.0", summary) - self.assertIn("Final comparison:", summary) - self.assertIn("press@pn_c1_8", summary) - self.assertIn("delta=-2.0", summary) - self.assertIn("dm2@pn_morifice_1", summary) - self.assertIn("delta=3.0", summary) - self.assertTrue(summary.endswith("\n")) - - def test_full_state_script_can_skip_artifact_files(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) - run, returned_output_dir = run_test_mql_full_state_comparison( - TestMqlFullStateComparisonScriptConfig( - paths=TestMqlFullStateComparisonPathConfig( - archive_path=TEST_MQL_AME, - output_dir=output_dir, - ), - execution=TestMqlFullStateComparisonExecutionConfig( - write_summary=False, - write_comparison_csv=False, - data_paths=("press@pn_c1_8",), - solver=SolveIVPConfig(t_start=0.0, t_stop=0.0), - t_eval=(0.0,), - ), - ) - ) - - self.assertEqual(returned_output_dir, output_dir) - self.assertEqual(run.signal_count, 1) - self.assertFalse((output_dir / "test_mql_full_state_comparison_summary.txt").exists()) - self.assertFalse((output_dir / "test_mql_amesim_comparison.csv").exists()) - - def test_format_test_mql_full_state_comparison_summary(self) -> None: - run, _ = run_test_mql_full_state_comparison( - TestMqlFullStateComparisonScriptConfig( - paths=TestMqlFullStateComparisonPathConfig(archive_path=TEST_MQL_AME), - execution=TestMqlFullStateComparisonExecutionConfig( - write_summary=False, - write_comparison_csv=False, - data_paths=("press@pn_c1_8",), - solver=SolveIVPConfig(t_start=0.0, t_stop=0.0), - t_eval=(0.0,), - ), - ) - ) - - summary = format_test_mql_full_state_comparison_summary(run) - - self.assertIn("Model: test_mql", summary) - self.assertIn("Output schema signals: 858", summary) - self.assertIn("Compared signals: 1", summary) - self.assertIn("Largest absolute error: press@pn_c1_8=", summary) - self.assertIn("Largest final endpoint error: press@pn_c1_8=", summary) - self.assertIn("Metrics by max absolute error:", summary) - self.assertIn("Endpoint diagnostics by final absolute error:", summary) - self.assertIn("Largest endpoint chamber RHS breakdown: pn_c1_8", summary) - self.assertTrue(summary.endswith("\n")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_simulation_paths.py b/tests/test_simulation_paths.py deleted file mode 100644 index 0a7b8cd..0000000 --- a/tests/test_simulation_paths.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.testmodel.run import prepare_testmodel_run -from app.simulation.paths import ( - PROJECT_ROOT, - SIMULATION_RUNS_DIR, -) - - -class SimulationPathTests(unittest.TestCase): - def test_default_run_paths_are_independent_of_module_depth(self) -> None: - prepared = prepare_testmodel_run() - - self.assertEqual(prepared.repo_root, PROJECT_ROOT) - self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR) - - def test_testmodel_baselines_live_with_the_test_assets(self) -> None: - baseline_dir = PROJECT_ROOT / "tests" / "data" / "testmodel" - - self.assertTrue((baseline_dir / "testmodel_primary_series.csv").is_file()) - self.assertTrue( - ( - baseline_dir / "testmodel_modelica_comparison_summary.txt" - ).is_file() - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_simulation_performance.py b/tests/test_simulation_performance.py index 67385e6..58cdf50 100644 --- a/tests/test_simulation_performance.py +++ b/tests/test_simulation_performance.py @@ -27,46 +27,6 @@ def profiling_mode(mode: str): class SimulationPerformanceTests(unittest.TestCase): - def test_off_mode_decorators_return_original_callables(self) -> None: - with profiling_mode("off") as module: - def phase_function(value: int) -> int: - return value + 1 - - def property_function(value: int) -> int: - return value * 2 - - self.assertIs( - module.profile_phase("phase")(phase_function), - phase_function, - ) - self.assertIs( - module.profile_phase("phase", minimum_mode="off")(phase_function), - phase_function, - ) - self.assertIs( - module.profile_property("property")(property_function), - property_function, - ) - self.assertIs( - module.profile_property( - "property", - minimum_mode="off", - )(property_function), - property_function, - ) - - with module.profile_run() as trace: - self.assertEqual(phase_function(2), 3) - - self.assertEqual( - trace.snapshot(), - { - "mode": "off", - "phases": {}, - "properties": {}, - "propertyOutermostNs": 0, - }, - ) def test_standard_mode_records_nested_inclusive_and_self_time(self) -> None: with profiling_mode("standard") as module: @@ -134,94 +94,8 @@ class SimulationPerformanceTests(unittest.TestCase): self.assertEqual(snapshot["phases"]["manual"]["inclusiveNs"], 10) self.assertEqual(snapshot["phases"]["simulation.total"]["selfNs"], 20) - def test_nested_properties_only_add_outermost_time_once(self) -> None: - with profiling_mode("standard") as module: - class TestMedium: - name = "TestMedium" - @module.profile_property("inner", minimum_mode="standard") - def inner(self) -> float: - return 1.0 - @module.profile_property("outer", minimum_mode="standard") - def outer(self) -> float: - return self.inner() - - medium = TestMedium() - clock = iter((0, 10, 20, 30, 50, 80)) - with patch.object(module, "perf_counter_ns", side_effect=clock): - with module.profile_run() as trace: - self.assertEqual(medium.outer(), 1.0) - - snapshot = trace.snapshot() - outer = snapshot["properties"]["semantic.TestMedium.outer"] - inner = snapshot["properties"]["semantic.TestMedium.inner"] - self.assertEqual(outer["inclusiveNs"], 40) - self.assertEqual(outer["selfNs"], 30) - self.assertEqual(inner["inclusiveNs"], 10) - self.assertEqual(snapshot["propertyOutermostNs"], 40) - - def test_audit_resets_exact_input_shadow_and_records_iterations_and_cache(self) -> None: - with profiling_mode("audit") as module: - class TestMedium: - name = "AuditMedium" - - @module.profile_property("inverse", track_cache=True) - @lru_cache(maxsize=4) - def inverse(self, value: float) -> float: - module.record_property_iterations( - "inverse", - 3 if value == 1.0 else 4, - value == 1.0, - ) - return value * 2.0 - - medium = TestMedium() - - @module.profile_phase("closure", reset_property_shadow=True) - def closure() -> None: - medium.inverse(1.0) - medium.inverse(1.0) - medium.inverse(2.0) - - with module.profile_run() as trace: - closure() - closure() - - metric = trace.snapshot()["properties"][ - "semantic.AuditMedium.inverse" - ] - self.assertEqual(metric["calls"], 6) - self.assertEqual(metric["exactInputUnique"], 4) - self.assertEqual(metric["exactInputRepeats"], 2) - self.assertEqual(metric["iterationCalls"], 2) - self.assertEqual(metric["iterationTotal"], 7) - self.assertEqual(metric["iterationMax"], 4) - self.assertEqual(metric["iterationConverged"], 1) - self.assertEqual(metric["iterationNonconverged"], 1) - self.assertEqual(metric["cacheLookups"], 6) - self.assertEqual(metric["cacheHits"], 4) - self.assertEqual(metric["cacheMisses"], 2) - - self.assertTrue(callable(medium.inverse.cache_clear)) - self.assertTrue(callable(medium.inverse.cache_info)) - self.assertTrue(callable(medium.inverse.cache_parameters)) - medium.inverse.cache_clear() - self.assertEqual(medium.inverse.cache_info().currsize, 0) - - def test_audit_minimum_decorator_is_absent_in_standard_mode(self) -> None: - with profiling_mode("standard") as module: - def kernel(value: float) -> float: - return value - - self.assertIs( - module.profile_property( - "kernel", - layer="kernel", - minimum_mode="audit", - )(kernel), - kernel, - ) def test_async_profile_runs_are_context_isolated(self) -> None: with profiling_mode("standard") as module: @@ -246,3 +120,12 @@ class SimulationPerformanceTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + +class NativeOrchestrationPerformanceTests(unittest.TestCase): + def test_off_mode_keeps_the_original_phase_callable(self): + with profiling_mode('off') as module: + def work(): return 1 + self.assertIs(module.profile_phase('work')(work), work) + with module.profile_run() as trace: + work() + self.assertEqual(trace.snapshot(), {'mode': 'off', 'phases': {}}) diff --git a/tests/test_simulation_performance_pipeline.py b/tests/test_simulation_performance_pipeline.py deleted file mode 100644 index 782e912..0000000 --- a/tests/test_simulation_performance_pipeline.py +++ /dev/null @@ -1,242 +0,0 @@ -from __future__ import annotations - -import json -import os -from pathlib import Path -import subprocess -import sys -import textwrap -import unittest - - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - - -class SimulationPerformancePipelineTests(unittest.TestCase): - def test_off_mode_does_not_change_result_diagnostics(self) -> None: - script = textwrap.dedent( - """ - from app.main import ( - build_reactflow_system_xml, - run_system_xml_simulation, - ) - from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project - - result = run_system_xml_simulation( - build_reactflow_system_xml(amesim_pnl00r_project()) - ) - assert result["success"], result["message"] - print("performance" in result["diagnostics"]) - """ - ) - environment = os.environ.copy() - environment["SIMULATIONAPP_PROFILE"] = "off" - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=PROJECT_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - - self.assertEqual(completed.stdout.strip(), "False") - - def test_standard_mode_reports_coarse_pipeline_phases_per_run(self) -> None: - script = textwrap.dedent( - """ - import json - - from app.main import ( - build_reactflow_system_xml, - run_system_xml_simulation, - ) - from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project - - xml = build_reactflow_system_xml(amesim_pnl00r_project()) - snapshots = [] - for _ in range(2): - result = run_system_xml_simulation(xml) - assert result["success"], result["message"] - performance = result["diagnostics"]["performance"] - snapshots.append( - { - "mode": performance["mode"], - "calls": { - name: metrics["calls"] - for name, metrics in performance["phases"].items() - }, - "propertyCount": len(performance["properties"]), - } - ) - print(json.dumps(snapshots)) - """ - ) - environment = os.environ.copy() - environment["SIMULATIONAPP_PROFILE"] = "standard" - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=PROJECT_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - snapshots = json.loads(completed.stdout) - required_phases = { - "simulation.total", - "simulation.xml_validation", - "simulation.network_compilation", - "simulation.system_construction", - "simulation.sample_initialization", - "simulation.integration", - "simulation.postprocessing", - "simulation.result_assembly", - "simulation.response_assembly", - } - audit_only_phases = { - "simulation.rhs", - "simulation.closure", - "simulation.refresh", - "simulation.pressure_flow", - "simulation.pneumatic_volume", - "simulation.stream", - "simulation.signal", - "simulation.derivatives", - } - - self.assertEqual(len(snapshots), 2) - for snapshot in snapshots: - self.assertEqual(snapshot["mode"], "standard") - self.assertTrue(required_phases.issubset(snapshot["calls"])) - self.assertEqual(snapshot["calls"]["simulation.total"], 1) - self.assertEqual(snapshot["calls"]["simulation.xml_validation"], 1) - self.assertEqual(snapshot["calls"]["simulation.network_compilation"], 1) - self.assertEqual(snapshot["calls"]["simulation.integration"], 1) - self.assertEqual(snapshot["calls"]["simulation.postprocessing"], 1) - self.assertEqual(snapshot["calls"]["simulation.result_assembly"], 1) - self.assertEqual(snapshot["calls"]["simulation.response_assembly"], 1) - self.assertTrue(audit_only_phases.isdisjoint(snapshot["calls"])) - self.assertEqual(snapshot["propertyCount"], 0) - - def test_audit_mode_reports_hot_phases_and_property_metrics(self) -> None: - script = textwrap.dedent( - """ - import json - - from app.main import ( - build_reactflow_system_xml, - run_system_xml_simulation, - ) - from tests.test_amesim_pnl00r_xml import amesim_pnl00r_project - - result = run_system_xml_simulation( - build_reactflow_system_xml(amesim_pnl00r_project()) - ) - assert result["success"], result["message"] - print(json.dumps(result["diagnostics"]["performance"])) - """ - ) - environment = os.environ.copy() - environment["SIMULATIONAPP_PROFILE"] = "audit" - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=PROJECT_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - snapshot = json.loads(completed.stdout) - - for phase in ( - "simulation.rhs", - "simulation.closure", - "simulation.refresh", - "simulation.pressure_flow", - "simulation.pneumatic_volume", - "simulation.stream", - "simulation.signal", - "simulation.derivatives", - ): - self.assertGreater(snapshot["phases"][phase]["calls"], 0) - self.assertGreater(len(snapshot["properties"]), 0) - self.assertGreater(snapshot["propertyOutermostNs"], 0) - - def test_audit_property_totals_match_run_local_cache(self) -> None: - script = textwrap.dedent( - """ - import json - - from app.simulation.components.amesim.media.mediums import ( - AmesimHeliumPengRobinsonMedium, - ) - from app.simulation.performance import profile_run - from app.simulation.property_cache import property_cache_run - - medium = AmesimHeliumPengRobinsonMedium() - pressure = 15.3e6 - temperature = 293.15 - volume = 0.057 - with property_cache_run() as cache: - assert cache is not None - with profile_run() as trace: - density = medium.density(pressure, temperature) - medium.density(pressure, temperature) - medium.isentropic_density_pressure_factor( - pressure, temperature, 1.0e6 - ) - medium.isentropic_density_pressure_factor( - pressure, temperature, 1.0e6 - ) - enthalpy = medium.specific_enthalpy_at_pressure( - pressure, temperature - ) - medium.temperature_from_pressure_enthalpy(pressure, enthalpy) - medium.temperature_from_pressure_enthalpy(pressure, enthalpy) - mass = density * volume - energy = mass * medium.specific_internal_energy_at_pressure( - pressure, temperature - ) - medium.properties_from_mU(mass, energy, volume) - medium.properties_from_mU(mass, energy, volume) - cache_info = cache.info() - snapshot = trace.snapshot() - cached_properties = [ - metrics - for metrics in snapshot["properties"].values() - if metrics["cacheLookups"] > 0 - ] - print(json.dumps({ - "cacheHits": cache_info.hits, - "cacheMisses": cache_info.misses, - "propertyHits": sum(item["cacheHits"] for item in cached_properties), - "propertyMisses": sum(item["cacheMisses"] for item in cached_properties), - })) - """ - ) - environment = os.environ.copy() - environment["SIMULATIONAPP_PROFILE"] = "audit" - environment["SIMULATIONAPP_PROPERTY_CACHE"] = "on" - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=PROJECT_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - totals = json.loads(completed.stdout) - - self.assertGreater(totals["cacheHits"], 0) - self.assertGreater(totals["cacheMisses"], 0) - self.assertEqual(totals["cacheHits"], totals["propertyHits"]) - self.assertEqual(totals["cacheMisses"], totals["propertyMisses"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_simulation_property_cache.py b/tests/test_simulation_property_cache.py deleted file mode 100644 index 88f9225..0000000 --- a/tests/test_simulation_property_cache.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -import asyncio -from concurrent.futures import ThreadPoolExecutor -from math import nextafter -import os -from pathlib import Path -import subprocess -import sys -import textwrap -from threading import Barrier -import unittest - -from app.simulation.components.amesim.media.mediums import ( - AmesimHeliumPengRobinsonMedium, -) -from app.simulation.property_cache import ( - cache_property_calculation, - current_property_cache, - property_cache_run, -) - - -PROJECT_ROOT = Path(__file__).resolve().parent.parent - - -class SimulationPropertyCacheTests(unittest.TestCase): - def test_reuses_only_exact_inputs(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - pressure = 15.3e6 - temperature = 293.15 - - with property_cache_run() as cache: - assert cache is not None - first = medium.density(pressure, temperature) - repeated = medium.density(pressure, temperature) - changed = medium.density(nextafter(pressure, float("inf")), temperature) - info = cache.info() - - self.assertEqual(repeated, first) - self.assertNotEqual(changed, first) - self.assertEqual(info.hits, 1) - self.assertEqual(info.misses, 2) - self.assertEqual(info.current_entries, 2) - - def test_failed_calculations_are_not_cached(self) -> None: - class FailingProperty: - def __init__(self) -> None: - self.calls = 0 - - @cache_property_calculation("failure") - def calculate(self, value: float) -> float: - self.calls += 1 - raise ValueError(f"invalid {value}") - - owner = FailingProperty() - with property_cache_run() as cache: - assert cache is not None - for _ in range(2): - with self.assertRaisesRegex(ValueError, "invalid"): - owner.calculate(1.0) - info = cache.info() - - self.assertEqual(owner.calls, 2) - self.assertEqual(info.hits, 0) - self.assertEqual(info.misses, 2) - self.assertEqual(info.current_entries, 0) - - def test_lru_capacity_is_bounded_and_oldest_entry_is_evicted(self) -> None: - class CachedProperty: - def __init__(self) -> None: - self.calls = 0 - - @cache_property_calculation("bounded") - def calculate(self, value: int) -> int: - self.calls += 1 - return value * 10 - - owner = CachedProperty() - with property_cache_run(max_entries=2) as cache: - assert cache is not None - owner.calculate(1) - owner.calculate(2) - owner.calculate(1) - owner.calculate(3) - second = owner.calculate(2) - info = cache.info() - - self.assertEqual(second, 20) - self.assertEqual(owner.calls, 4) - self.assertEqual(info.current_entries, 2) - self.assertEqual(info.evictions, 2) - - def test_each_run_gets_an_independent_cache_and_releases_context(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - with property_cache_run() as first_cache: - assert first_cache is not None - medium.density(100_000.0, 300.0) - medium.density(100_000.0, 300.0) - first_info = first_cache.info() - - self.assertIsNone(current_property_cache()) - - with property_cache_run() as second_cache: - assert second_cache is not None - medium.density(100_000.0, 300.0) - second_info = second_cache.info() - - self.assertIsNot(first_cache, second_cache) - self.assertEqual(first_info.hits, 1) - self.assertEqual(second_info.hits, 0) - self.assertEqual(second_info.misses, 1) - self.assertIsNone(current_property_cache()) - - def test_async_tasks_do_not_share_run_local_caches(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - - async def exercise(pressure: float) -> tuple[int, int, int]: - with property_cache_run() as cache: - assert cache is not None - await asyncio.sleep(0) - medium.density(pressure, 300.0) - medium.density(pressure, 300.0) - info = cache.info() - return id(cache), info.hits, info.misses - - async def run_both() -> list[tuple[int, int, int]]: - return list( - await asyncio.gather( - exercise(100_000.0), - exercise(200_000.0), - ) - ) - - results = asyncio.run(run_both()) - - self.assertNotEqual(results[0][0], results[1][0]) - self.assertEqual(results[0][1:], (1, 1)) - self.assertEqual(results[1][1:], (1, 1)) - - def test_worker_threads_do_not_share_run_local_caches(self) -> None: - medium = AmesimHeliumPengRobinsonMedium() - barrier = Barrier(2) - - def exercise(pressure: float) -> tuple[int, int, int]: - with property_cache_run() as cache: - assert cache is not None - barrier.wait(timeout=5.0) - medium.density(pressure, 300.0) - medium.density(pressure, 300.0) - info = cache.info() - return id(cache), info.hits, info.misses - - with ThreadPoolExecutor(max_workers=2) as executor: - results = list( - executor.map( - exercise, - (100_000.0, 200_000.0), - ) - ) - - self.assertNotEqual(results[0][0], results[1][0]) - self.assertEqual(results[0][1:], (1, 1)) - self.assertEqual(results[1][1:], (1, 1)) - - def test_cache_on_and_off_produce_identical_helium_results(self) -> None: - script = textwrap.dedent( - """ - import hashlib - import json - - from app.main import build_reactflow_system_xml, run_system_xml_simulation - from tests.test_amesim_pnvo001_signal_xml import ( - high_pressure_helium_step_project, - ) - - result = run_system_xml_simulation( - build_reactflow_system_xml(high_pressure_helium_step_project()) - ) - assert result["success"], result["message"] - payload = json.dumps( - { - "status": result["status"], - "series": result["series"], - "final": result["final"], - }, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ).encode("utf-8") - print(hashlib.sha256(payload).hexdigest()) - """ - ) - hashes: list[str] = [] - for enabled in ("on", "off"): - environment = os.environ.copy() - environment["SIMULATIONAPP_PROFILE"] = "off" - environment["SIMULATIONAPP_PROPERTY_CACHE"] = enabled - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=PROJECT_ROOT, - env=environment, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - hashes.append(completed.stdout.strip()) - - self.assertEqual(hashes[0], hashes[1]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_simulation_safety.py b/tests/test_simulation_safety.py index 1af6cab..fb8bc8e 100644 --- a/tests/test_simulation_safety.py +++ b/tests/test_simulation_safety.py @@ -9,10 +9,9 @@ from app.simulation.components.amesim.mechanical.translational import ( ) from app.simulation.components.amesim.signals.sources import AmesimStep0 from app.simulation.core.medium import IdealGasMedium -from app.simulation.solvers.solver import SolveIVPConfig -from app.simulation.systems.generic import ( +from app.simulation.config import SolveIVPConfig +from app.simulation.sampling import ( SimulationSampleTimeError, - simulation_preparation_issues, simulation_sample_times, ) from app.simulation.systems.network import SimulationNetwork @@ -193,35 +192,17 @@ class SimulationSampleTimeSafetyTests(unittest.TestCase): ) -class PhysicalIslandSafetyTests(unittest.TestCase): - def test_signal_fanout_does_not_hide_an_unanchored_physical_island(self) -> None: - issues = simulation_preparation_issues( - _fanout_mechanical_network(second_island_has_storage=False) - ) - island_issues = [ - issue - for issue in issues - if issue.code == "ALGEBRAIC_ISLAND_HAS_NO_STORAGE" - ] - - self.assertEqual(len(island_issues), 1) - self.assertIn("force_b", island_issues[0].message) - self.assertIn("zero_b", island_issues[0].message) - self.assertNotIn("command", island_issues[0].message) - self.assertNotIn("mass_a", island_issues[0].message) - - def test_signal_fanout_between_two_anchored_physical_islands_is_allowed( - self, - ) -> None: - issues = simulation_preparation_issues( - _fanout_mechanical_network(second_island_has_storage=True) - ) - - self.assertNotIn( - "ALGEBRAIC_ISLAND_HAS_NO_STORAGE", - {issue.code for issue in issues}, - ) if __name__ == "__main__": unittest.main() + +class NativePhysicalIslandSafetyTests(unittest.TestCase): + def test_unanchored_force_island_is_rejected(self): + from app.simulation.native_codegen.compiler import NativeCapabilityError, compile_native_program + with self.assertRaises(NativeCapabilityError): + compile_native_program(_fanout_mechanical_network(second_island_has_storage=False)) + + def test_two_anchored_islands_are_supported(self): + from app.simulation.native_codegen.compiler import compile_native_program + self.assertEqual(len(compile_native_program(_fanout_mechanical_network(second_island_has_storage=True)).state_keys), 4) diff --git a/tests/test_simulation_warmup.py b/tests/test_simulation_warmup.py index 1acf0cf..133fc8e 100644 --- a/tests/test_simulation_warmup.py +++ b/tests/test_simulation_warmup.py @@ -8,7 +8,6 @@ from unittest.mock import patch from app.main import _app_lifespan, app from app.simulation.warmup import ( SimulationWarmupReport, - _run_numerical_warmup, _reset_simulation_warmup_for_tests, warm_up_simulation_runtime, ) @@ -17,6 +16,9 @@ from app.simulation.warmup import ( class SimulationWarmupTests(unittest.TestCase): def setUp(self) -> None: _reset_simulation_warmup_for_tests() + self.engine_environment = patch.dict(os.environ, {"SIMULATION_NUMERIC_ENGINE": "native"}) + self.engine_environment.start() + self.addCleanup(self.engine_environment.stop) def tearDown(self) -> None: _reset_simulation_warmup_for_tests() @@ -24,7 +26,7 @@ class SimulationWarmupTests(unittest.TestCase): def test_warmup_runs_only_once_per_process(self) -> None: with ( patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}), - patch("app.simulation.warmup._run_numerical_warmup") as run_warmup, + patch("app.simulation.warmup._run_native_warmup") as run_warmup, ): first = warm_up_simulation_runtime() second = warm_up_simulation_runtime() @@ -36,7 +38,7 @@ class SimulationWarmupTests(unittest.TestCase): def test_disabled_warmup_does_not_touch_numerical_runtime(self) -> None: with ( patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "off"}), - patch("app.simulation.warmup._run_numerical_warmup") as run_warmup, + patch("app.simulation.warmup._run_native_warmup") as run_warmup, ): report = warm_up_simulation_runtime() @@ -47,7 +49,7 @@ class SimulationWarmupTests(unittest.TestCase): with ( patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}), patch( - "app.simulation.warmup._run_numerical_warmup", + "app.simulation.warmup._run_native_warmup", side_effect=RuntimeError("broken warmup"), ), self.assertLogs("app.simulation.warmup", level="ERROR"), @@ -61,7 +63,7 @@ class SimulationWarmupTests(unittest.TestCase): with ( patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}), patch( - "app.simulation.warmup._run_numerical_warmup", + "app.simulation.warmup._run_native_warmup", side_effect=MemoryError("out of memory"), ), self.assertRaises(MemoryError), @@ -77,6 +79,7 @@ class SimulationWarmupTests(unittest.TestCase): return_value=report, ) as warmup: async with _app_lifespan(app): + self.assertEqual(app.state.simulation_numeric_engine, "native") self.assertEqual( app.state.simulation_warmup, report.as_dict(), @@ -85,6 +88,31 @@ class SimulationWarmupTests(unittest.TestCase): asyncio.run(enter_lifespan()) + def test_default_startup_checks_native_without_python_warmup(self) -> None: + with ( + patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}), + patch("app.simulation.warmup._run_native_warmup") as native, + ): + os.environ.pop("SIMULATION_NUMERIC_ENGINE", None) + + async def enter_lifespan() -> None: + async with _app_lifespan(app): + self.assertEqual(app.state.simulation_numeric_engine, "native") + self.assertEqual(app.state.simulation_warmup["status"], "completed") + + asyncio.run(enter_lifespan()) + native.assert_called_once_with() + + def test_native_failure_does_not_fall_back_to_python(self) -> None: + with ( + patch.dict(os.environ, {"SIMULATION_NUMERIC_ENGINE": "native", "SIMULATIONAPP_WARMUP": "on"}), + patch("app.simulation.warmup._run_native_warmup", side_effect=RuntimeError("C compiler unavailable")), + self.assertLogs("app.simulation.warmup", level="ERROR"), + ): + report = warm_up_simulation_runtime() + self.assertEqual(report.status, "failed") + self.assertIn("C compiler unavailable", report.error or "") + def test_real_numerical_warmup_completes(self) -> None: with patch.dict(os.environ, {"SIMULATIONAPP_WARMUP": "on"}): report = warm_up_simulation_runtime() @@ -92,29 +120,6 @@ class SimulationWarmupTests(unittest.TestCase): self.assertEqual(report.status, "completed", report.error) self.assertGreater(report.duration_ms, 0.0) - def test_numerical_warmup_exercises_sparse_lsmr_algebraic_path(self) -> None: - import scipy.optimize - - actual_least_squares = scipy.optimize.least_squares - optimizer_calls: list[dict[str, object]] = [] - - def recording_least_squares(*args, **kwargs): - optimizer_calls.append(dict(kwargs)) - return actual_least_squares(*args, **kwargs) - - with patch.object( - scipy.optimize, - "least_squares", - recording_least_squares, - ): - _run_numerical_warmup() - - self.assertEqual(len(optimizer_calls), 1) - call = optimizer_calls[0] - self.assertEqual(call["tr_solver"], "lsmr") - sparsity = call["jac_sparsity"] - self.assertEqual(sparsity.shape, (2, 2)) - self.assertEqual(sparsity.nnz, 2) if __name__ == "__main__": diff --git a/tests/test_sparse_secant_jacobian.py b/tests/test_sparse_secant_jacobian.py deleted file mode 100644 index 100d082..0000000 --- a/tests/test_sparse_secant_jacobian.py +++ /dev/null @@ -1,950 +0,0 @@ -from __future__ import annotations - -import unittest - -import numpy as np -from scipy.integrate._ivp.common import num_jac -from scipy.optimize._numdiff import group_columns -from scipy.sparse import csc_matrix, csr_matrix - -from app.simulation.solvers.jacobian import ( - ExactColumnsUnavailable, - SparseSecantJacobian, -) -from app.simulation.solvers.solver import IntegrationCancelled - - -class _SwitchableLinearRhs: - def __init__(self, matrix: np.ndarray) -> None: - self.matrix = np.asarray(matrix, dtype=float) - self.evaluation_count = 0 - - def __call__(self, time: float, state: np.ndarray) -> np.ndarray: - del time - self.evaluation_count += 1 - return self.matrix @ np.asarray(state, dtype=float) - - -class SparseSecantJacobianTests(unittest.TestCase): - def test_first_call_builds_full_sparse_finite_difference_jacobian(self) -> None: - matrix = np.asarray( - ( - (2.0, 0.0, -1.0), - (0.0, 3.0, 0.0), - (4.0, 0.0, 5.0), - ) - ) - evaluator = _SwitchableLinearRhs(matrix) - builder = SparseSecantJacobian( - evaluator, - csr_matrix(matrix != 0.0), - atol=np.full(3, 1.0e-8), - ) - - jacobian = builder(0.0, np.asarray((1.0, -2.0, 0.5))) - - np.testing.assert_allclose(jacobian.toarray(), matrix, rtol=1.0e-7) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["fullBuildCount"], 1) - self.assertEqual(diagnostics["secantReuseCount"], 0) - self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) - self.assertGreater( - diagnostics["finiteDifferenceRhsEvaluationCount"], - 0, - ) - self.assertEqual( - evaluator.evaluation_count, - diagnostics["baseRhsEvaluationCount"] - + diagnostics["finiteDifferenceRhsEvaluationCount"], - ) - - def test_same_time_secant_is_reused_once_after_directional_audit(self) -> None: - matrix = np.asarray(((2.0, -1.0), (0.5, 4.0))) - evaluator = _SwitchableLinearRhs(matrix) - builder = SparseSecantJacobian( - evaluator, - csr_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - ) - builder(0.0, np.asarray((1.0, 1.0))) - first = np.asarray((1.5, -0.5)) - second = np.asarray((1.75, -0.25)) - builder.observe(0.1, first, matrix @ first) - builder.observe(0.1, second, matrix @ second) - - reused = builder(0.1, second) - - np.testing.assert_allclose(reused.toarray(), matrix, rtol=1.0e-7) - after_reuse = builder.diagnostics() - self.assertEqual(after_reuse["fullBuildCount"], 1) - self.assertEqual(after_reuse["secantReuseCount"], 1) - self.assertEqual(after_reuse["jvAuditEvaluationCount"], 1) - self.assertEqual(after_reuse["lastDecision"], "secantReuse") - self.assertEqual(after_reuse["jacobianEvaluationCount"], 2) - self.assertEqual( - after_reuse["segments"][0]["jvAuditRhsEvaluationCount"], - 1, - ) - self.assertGreater(after_reuse["segments"][0]["assemblySeconds"], 0.0) - - rebuilt = builder(0.1, second) - - np.testing.assert_allclose(rebuilt.toarray(), matrix, rtol=1.0e-7) - self.assertEqual(builder.diagnostics()["fullBuildCount"], 2) - - def test_failed_directional_audit_falls_back_to_full_refresh(self) -> None: - initial_matrix = np.eye(2) - changed_matrix = np.asarray(((1.0, 0.0), (0.0, 10.0))) - evaluator = _SwitchableLinearRhs(initial_matrix) - builder = SparseSecantJacobian( - evaluator, - csr_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - audit_relative_tolerance=1.0e-3, - ) - builder(0.0, np.asarray((1.0, 1.0))) - evaluator.matrix = changed_matrix - first = np.asarray((1.0, 1.0)) - second = np.asarray((2.0, 1.0)) - builder.observe(0.2, first, changed_matrix @ first) - builder.observe(0.2, second, changed_matrix @ second) - - refreshed = builder(0.2, second) - - np.testing.assert_allclose( - refreshed.toarray(), - changed_matrix, - rtol=1.0e-7, - ) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["fullBuildCount"], 2) - self.assertEqual(diagnostics["secantReuseCount"], 0) - self.assertEqual(diagnostics["auditFailureCount"], 1) - self.assertEqual(diagnostics["jvAuditEvaluationCount"], 1) - self.assertEqual(diagnostics["lastDecision"], "auditFallback") - - def test_directional_audit_does_not_swallow_cancellation(self) -> None: - matrix = np.asarray(((2.0, -1.0), (0.5, 4.0))) - cancel_next = False - - def evaluator(_time: float, state: np.ndarray) -> np.ndarray: - nonlocal cancel_next - if cancel_next: - cancel_next = False - raise IntegrationCancelled - return matrix @ np.asarray(state, dtype=float) - - builder = SparseSecantJacobian( - evaluator, - csr_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - ) - builder(0.0, np.asarray((1.0, 1.0))) - first = np.asarray((1.5, -0.5)) - second = np.asarray((1.75, -0.25)) - builder.observe(0.1, first, matrix @ first) - builder.observe(0.1, second, matrix @ second) - cancel_next = True - - with self.assertRaises(IntegrationCancelled): - builder(0.1, second) - - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["fullBuildCount"], 1) - self.assertEqual(diagnostics["auditFailureCount"], 0) - self.assertEqual(diagnostics["secantReuseCount"], 0) - - def test_segment_reset_forces_a_new_full_build(self) -> None: - matrix = np.asarray(((3.0, 0.0), (0.0, -2.0))) - evaluator = _SwitchableLinearRhs(matrix) - builder = SparseSecantJacobian( - evaluator, - csr_matrix(matrix != 0.0), - atol=1.0e-8, - ) - state = np.asarray((2.0, 4.0)) - builder(0.0, state) - - builder.start_segment() - rebuilt = builder(1.0, state) - - np.testing.assert_allclose(rebuilt.toarray(), matrix, rtol=1.0e-7) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["segmentStartCount"], 1) - self.assertEqual(diagnostics["fullBuildCount"], 2) - self.assertEqual(diagnostics["lastDecision"], "fullBuild") - self.assertEqual(len(diagnostics["segments"]), 2) - self.assertEqual(diagnostics["segments"][0]["fullBuildCount"], 1) - self.assertEqual(diagnostics["segments"][1]["fullBuildCount"], 1) - - def test_exact_row_replaces_finite_difference_and_secant_row(self) -> None: - def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: - del time - return np.asarray( - ( - state[0] * state[0] + 3.0 * state[1], - -2.0 * state[0] + state[1], - ) - ) - - builder = SparseSecantJacobian( - nonlinear_rhs, - csr_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - exact_rows={0: {0: 7.0, 1: 8.0}}, - ) - - jacobian = builder(0.0, np.asarray((2.0, 1.0))).toarray() - - np.testing.assert_array_equal(jacobian[0], np.asarray((7.0, 8.0))) - np.testing.assert_allclose(jacobian[1], np.asarray((-2.0, 1.0)), rtol=1.0e-7) - - def test_exact_columns_match_dense_num_jac_and_use_normalized_order(self) -> None: - def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: - del time - return np.asarray( - ( - state[0] * state[0] + state[1] * state[2], - np.sin(state[0]) + 3.0 * state[1] - state[2], - np.exp(state[2]) + state[0] * state[1], - ) - ) - - provider_calls: list[tuple[float, np.ndarray, tuple[int, ...]]] = [] - - def exact_column_provider( - time: float, - state: np.ndarray, - columns: tuple[int, ...], - ) -> np.ndarray: - provider_calls.append((time, state.copy(), columns)) - self.assertEqual(columns, (0, 2)) - return np.asarray( - ( - (2.0 * state[0], state[1]), - (np.cos(state[0]), -1.0), - (state[1], np.exp(state[2])), - ) - ) - - state = np.asarray((1.25, -0.75, 0.2)) - atol = np.full(3, 1.0e-8) - structure = csc_matrix(np.ones((3, 3), dtype=bool)) - base_rhs = nonlinear_rhs(0.3, state) - - def vectorized_rhs(time: float, states: np.ndarray) -> np.ndarray: - states_array = np.asarray(states, dtype=float) - if states_array.ndim == 1: - return nonlinear_rhs(time, states_array) - return np.column_stack( - [ - nonlinear_rhs(time, states_array[:, column]) - for column in range(states_array.shape[1]) - ] - ) - - expected, _factor = num_jac( - vectorized_rhs, - 0.3, - state, - base_rhs, - atol, - None, - ) - builder = SparseSecantJacobian( - nonlinear_rhs, - structure, - atol=atol, - exact_columns=((2, 0), exact_column_provider), - max_consecutive_reuses=0, - ) - - actual = builder(0.3, state).toarray() - - np.testing.assert_allclose(actual, expected, rtol=2.0e-7, atol=2.0e-8) - self.assertEqual(len(provider_calls), 1) - self.assertEqual(provider_calls[0][0], 0.3) - np.testing.assert_array_equal(provider_calls[0][1], state) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["exactColumnCount"], 2) - self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 1) - self.assertEqual(diagnostics["colorGroupCount"], 1) - self.assertEqual( - diagnostics["exactColumnOutsidePatternNonzeroCount"], - 0, - ) - - def test_exact_columns_reduce_seed_zero_coloring(self) -> None: - matrix = np.asarray( - ( - (1.0, 2.0, 3.0, 4.0), - (5.0, 6.0, 7.0, 8.0), - (9.0, 10.0, 11.0, 12.0), - (13.0, 14.0, 15.0, 16.0), - ) - ) - structure = csc_matrix(np.ones((4, 4), dtype=bool)) - baseline = SparseSecantJacobian( - _SwitchableLinearRhs(matrix), - structure, - atol=1.0e-8, - max_consecutive_reuses=0, - ) - reduced = SparseSecantJacobian( - _SwitchableLinearRhs(matrix), - structure, - atol=1.0e-8, - exact_columns={1: matrix[:, 1], 3: matrix[:, 3]}, - max_consecutive_reuses=0, - ) - - self.assertEqual(baseline.diagnostics()["colorGroupCount"], 4) - diagnostics = reduced.diagnostics() - self.assertEqual(diagnostics["coloringSeed"], 0) - self.assertEqual(diagnostics["colorGroupCount"], 2) - self.assertEqual(diagnostics["exactColumnCount"], 2) - self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 2) - - def test_exact_column_nonzeros_outside_pattern_are_preserved(self) -> None: - evaluator = _SwitchableLinearRhs(np.eye(3)) - builder = SparseSecantJacobian( - evaluator, - csc_matrix(np.eye(3, dtype=bool)), - atol=1.0e-8, - exact_columns={1: np.asarray((4.0, 5.0, 6.0))}, - max_consecutive_reuses=0, - ) - - jacobian = builder(0.0, np.asarray((1.0, 2.0, 3.0))).toarray() - - np.testing.assert_array_equal(jacobian[:, 1], np.asarray((4.0, 5.0, 6.0))) - self.assertEqual( - builder.diagnostics()["exactColumnOutsidePatternNonzeroCount"], - 2, - ) - - def test_exact_rows_override_exact_column_intersections(self) -> None: - builder = SparseSecantJacobian( - _SwitchableLinearRhs(np.eye(3)), - csc_matrix(np.eye(3, dtype=bool)), - atol=1.0e-8, - exact_rows={0: {0: 9.0, 1: 10.0}}, - exact_columns={1: np.asarray((4.0, 5.0, 6.0))}, - max_consecutive_reuses=0, - ) - - jacobian = builder(0.0, np.asarray((1.0, 2.0, 3.0))).toarray() - - np.testing.assert_array_equal(jacobian[0], np.asarray((9.0, 10.0, 0.0))) - np.testing.assert_array_equal(jacobian[1:, 1], np.asarray((5.0, 6.0))) - - def test_all_exact_columns_skip_every_finite_difference_rhs(self) -> None: - matrix = np.asarray(((2.0, -1.0), (3.0, 4.0))) - provider_calls = 0 - evaluator = _SwitchableLinearRhs(matrix) - - def exact_column_provider( - time: float, - state: np.ndarray, - columns: tuple[int, ...], - ) -> np.ndarray: - nonlocal provider_calls - del time, state - provider_calls += 1 - self.assertEqual(columns, (0, 1)) - return matrix - - builder = SparseSecantJacobian( - evaluator, - csc_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - exact_columns=((1, 0), exact_column_provider), - max_consecutive_reuses=0, - ) - - jacobian = builder(0.0, np.asarray((1.0, 2.0))).toarray() - - np.testing.assert_array_equal(jacobian, matrix) - self.assertEqual(provider_calls, 1) - self.assertEqual(evaluator.evaluation_count, 1) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["colorGroupCount"], 0) - self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 0) - self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) - self.assertEqual(diagnostics["finiteDifferenceRhsEvaluationCount"], 0) - - def test_exact_column_provider_runs_after_base_and_before_fd(self) -> None: - matrix = np.asarray( - ( - (2.0, 1.0, 0.0), - (0.0, 3.0, 4.0), - (5.0, 0.0, 6.0), - ) - ) - state = np.asarray((1.0, 2.0, 3.0)) - events: list[tuple[str, int]] = [] - evaluated_states: list[np.ndarray] = [] - rhs_epoch = 0 - - def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray: - nonlocal rhs_epoch - del time - rhs_epoch += 1 - events.append(("rhs", rhs_epoch)) - evaluated_states.append(evaluation_state.copy()) - return matrix @ evaluation_state - - def provider( - time: float, - provider_state: np.ndarray, - columns: tuple[int, ...], - ) -> np.ndarray: - del time - events.append(("provider", rhs_epoch)) - self.assertEqual(rhs_epoch, 1) - self.assertEqual(columns, (1,)) - np.testing.assert_array_equal(provider_state, state) - return matrix[:, [1]] - - builder = SparseSecantJacobian( - evaluator, - csc_matrix(matrix != 0.0), - atol=1.0e-8, - exact_columns=((1,), provider), - max_consecutive_reuses=0, - ) - - builder(0.0, state) - - self.assertEqual(events[0], ("rhs", 1)) - self.assertEqual(events[1], ("provider", 1)) - self.assertTrue(any(event[0] == "rhs" for event in events[2:])) - diagnostics = builder.diagnostics() - self.assertEqual( - len(evaluated_states), - 1 + diagnostics["colorGroupCount"], - ) - self.assertEqual( - diagnostics["finiteDifferenceRhsEvaluationCount"], - diagnostics["colorGroupCount"], - ) - for perturbed_state in evaluated_states[1:]: - self.assertEqual(perturbed_state[1], state[1]) - self.assertTrue(np.any(perturbed_state[[0, 2]] != state[[0, 2]])) - - def test_exact_column_capture_request_is_always_cancelled(self) -> None: - matrix = np.asarray(((2.0, 1.0), (0.0, 3.0))) - - class Provider: - def __init__(self) -> None: - self.pending = False - self.request_count = 0 - self.cancel_count = 0 - self.call_count = 0 - - def request_primal_capture(self) -> None: - self.request_count += 1 - self.pending = True - - def cancel_primal_capture(self) -> None: - self.cancel_count += 1 - self.pending = False - - def __call__( - self, - time: float, - state: np.ndarray, - columns: tuple[int, ...], - ) -> np.ndarray: - del time, state - self.call_count += 1 - self.assert_capture_was_cancelled(columns) - return matrix[:, [1]] - - def assert_capture_was_cancelled( - self, - columns: tuple[int, ...], - ) -> None: - if self.pending or columns != (1,): - raise AssertionError("The one-shot capture request leaked.") - - provider = Provider() - builder = SparseSecantJacobian( - _SwitchableLinearRhs(matrix), - csc_matrix(matrix != 0.0), - atol=1.0e-8, - exact_columns=((1,), provider), - max_consecutive_reuses=0, - ) - - jacobian = builder(0.0, np.asarray((1.0, 2.0))).toarray() - - np.testing.assert_allclose(jacobian, matrix, rtol=1.0e-7) - self.assertEqual(provider.request_count, 1) - self.assertEqual(provider.cancel_count, 1) - self.assertEqual(provider.call_count, 1) - self.assertFalse(provider.pending) - - def test_exact_column_provider_output_is_validated_and_errors_propagate(self) -> None: - state = np.asarray((1.0, 2.0, 3.0)) - structure = csc_matrix(np.eye(3, dtype=bool)) - - with self.assertRaisesRegex(ValueError, "duplicated"): - SparseSecantJacobian( - _SwitchableLinearRhs(np.eye(3)), - structure, - atol=1.0e-8, - exact_columns=((1, 1), lambda *_args: np.zeros((3, 2))), - ) - with self.assertRaisesRegex(ValueError, "out of range"): - SparseSecantJacobian( - _SwitchableLinearRhs(np.eye(3)), - structure, - atol=1.0e-8, - exact_columns=((3,), lambda *_args: np.zeros((3, 1))), - ) - - invalid_builders = ( - ( - SparseSecantJacobian( - _SwitchableLinearRhs(np.eye(3)), - structure, - atol=1.0e-8, - exact_columns=((0, 2), lambda *_args: np.zeros((2, 3))), - max_consecutive_reuses=0, - ), - "must return shape", - ), - ( - SparseSecantJacobian( - _SwitchableLinearRhs(np.eye(3)), - structure, - atol=1.0e-8, - exact_columns=( - (0, 2), - lambda *_args: np.asarray( - ((1.0, 0.0), (0.0, np.nan), (0.0, 1.0)) - ), - ), - max_consecutive_reuses=0, - ), - "finite values", - ), - ( - SparseSecantJacobian( - _SwitchableLinearRhs(np.eye(3)), - structure, - atol=1.0e-8, - exact_columns=( - (0, 2), - lambda *_args: { - 2: np.zeros(3), - 0: np.zeros(3), - }, - ), - max_consecutive_reuses=0, - ), - "normalized column order", - ), - ) - for builder, message in invalid_builders: - with self.subTest(message=message): - with self.assertRaisesRegex(ValueError, message): - builder(0.0, state) - - class ProviderFailure(RuntimeError): - pass - - def failing_provider(*_args): - raise ProviderFailure("provider failed") - - failure_evaluator = _SwitchableLinearRhs(np.eye(3)) - failing_builder = SparseSecantJacobian( - failure_evaluator, - structure, - atol=1.0e-8, - exact_columns=((0,), failing_provider), - max_consecutive_reuses=0, - ) - with self.assertRaisesRegex(ProviderFailure, "provider failed"): - failing_builder(0.0, state) - self.assertEqual(failure_evaluator.evaluation_count, 1) - failure_diagnostics = failing_builder.diagnostics() - self.assertEqual(failure_diagnostics["baseRhsEvaluationCount"], 1) - self.assertEqual( - failure_diagnostics["finiteDifferenceRhsEvaluationCount"], - 0, - ) - - def test_exact_column_provider_is_refreshed_after_segment_reset(self) -> None: - calls: list[tuple[float, np.ndarray, tuple[int, ...]]] = [] - - def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: - return np.asarray( - ( - 2.0 * state[0] + (1.0 + time) * state[1], - state[0] * state[1], - ) - ) - - def provider( - time: float, - state: np.ndarray, - columns: tuple[int, ...], - ) -> np.ndarray: - calls.append((time, state.copy(), columns)) - return np.asarray(((1.0 + time,), (state[0],))) - - builder = SparseSecantJacobian( - nonlinear_rhs, - csc_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - exact_columns=((1,), provider), - max_consecutive_reuses=0, - ) - first_state = np.asarray((1.0, 2.0)) - second_state = np.asarray((3.0, 4.0)) - - first = builder(0.0, first_state).toarray() - builder.start_segment() - second = builder(1.0, second_state).toarray() - - np.testing.assert_allclose(first[:, 1], np.asarray((1.0, 1.0))) - np.testing.assert_allclose(second[:, 1], np.asarray((2.0, 3.0))) - self.assertEqual([call[0] for call in calls], [0.0, 1.0]) - self.assertEqual([call[2] for call in calls], [(1,), (1,)]) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["fullBuildCount"], 2) - self.assertEqual(diagnostics["segmentStartCount"], 1) - self.assertEqual(len(diagnostics["segments"]), 2) - - def test_exact_column_unavailable_falls_back_and_next_build_recovers(self) -> None: - matrix = np.asarray( - ( - (2.0, 1.0, 3.0, -1.0), - (4.0, 5.0, -2.0, 6.0), - (7.0, -3.0, 8.0, 2.0), - (-4.0, 9.0, 1.0, 10.0), - ) - ) - state = np.asarray((1.0, -2.0, 0.5, 3.0)) - evaluated_states: list[np.ndarray] = [] - provider_call_count = 0 - - def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray: - del time - evaluated_states.append(evaluation_state.copy()) - return matrix @ evaluation_state - - def provider( - time: float, - provider_state: np.ndarray, - columns: tuple[int, ...], - ) -> np.ndarray: - nonlocal provider_call_count - del time, provider_state - provider_call_count += 1 - if provider_call_count == 1: - raise ExactColumnsUnavailable("tangent domain boundary") - return matrix[:, columns] - - structure = csc_matrix(np.ones((4, 4), dtype=bool)) - builder = SparseSecantJacobian( - evaluator, - structure, - atol=1.0e-8, - exact_columns=((3, 1), provider), - max_consecutive_reuses=0, - ) - baseline = SparseSecantJacobian( - _SwitchableLinearRhs(matrix), - structure, - atol=1.0e-8, - max_consecutive_reuses=0, - ) - - fallback = builder(0.0, state).toarray() - expected_fallback = baseline(0.0, state).toarray() - - np.testing.assert_array_equal(fallback, expected_fallback) - self.assertEqual(provider_call_count, 1) - self.assertTrue( - any( - evaluation_state[1] != state[1] - for evaluation_state in evaluated_states[1:] - ) - ) - self.assertTrue( - any( - evaluation_state[3] != state[3] - for evaluation_state in evaluated_states[1:] - ) - ) - fallback_diagnostics = builder.diagnostics() - self.assertEqual(fallback_diagnostics["baseRhsEvaluationCount"], 1) - self.assertEqual(fallback_diagnostics["originalColorGroupCount"], 4) - self.assertEqual(fallback_diagnostics["remainingColorGroupCount"], 2) - self.assertEqual(fallback_diagnostics["exactColumnBuildCount"], 0) - self.assertEqual(fallback_diagnostics["exactColumnFallbackCount"], 1) - self.assertEqual( - fallback_diagnostics["lastExactColumnFallbackReason"], - "tangent domain boundary", - ) - self.assertEqual( - fallback_diagnostics[ - "lastBuildFiniteDifferenceRhsEvaluationCount" - ], - 4, - ) - self.assertIsNotNone(builder._original_factor) - self.assertIsNone(builder._remaining_factor) - - recovery_start = len(evaluated_states) - recovered = builder(0.1, state).toarray() - recovery_states = evaluated_states[recovery_start:] - - np.testing.assert_allclose(recovered, matrix, rtol=1.0e-7) - self.assertEqual(provider_call_count, 2) - self.assertEqual(len(recovery_states), 3) - for perturbed_state in recovery_states[1:]: - self.assertEqual(perturbed_state[1], state[1]) - self.assertEqual(perturbed_state[3], state[3]) - recovery_diagnostics = builder.diagnostics() - self.assertEqual(recovery_diagnostics["exactColumnBuildCount"], 1) - self.assertEqual(recovery_diagnostics["exactColumnFallbackCount"], 1) - self.assertEqual(recovery_diagnostics["baseRhsEvaluationCount"], 2) - self.assertEqual( - recovery_diagnostics[ - "lastBuildFiniteDifferenceRhsEvaluationCount" - ], - 2, - ) - self.assertIsNotNone(builder._remaining_factor) - self.assertIsNot(builder._original_factor, builder._remaining_factor) - - def test_exact_column_subset_retry_matches_scipy_active_columns(self) -> None: - offset = np.asarray((1.0e8, -3.0e8, 2.0e8)) - slopes = np.asarray((1.0, 2.0, -3.0)) - state = np.asarray((1.0, -2.0, 0.5)) - evaluated_states: list[np.ndarray] = [] - - def evaluator(time: float, evaluation_state: np.ndarray) -> np.ndarray: - del time - evaluated_states.append(evaluation_state.copy()) - return offset + slopes * evaluation_state - - builder = SparseSecantJacobian( - evaluator, - csc_matrix(np.eye(3, dtype=bool)), - atol=1.0e-8, - exact_columns={1: np.asarray((0.0, slopes[1], 0.0))}, - max_consecutive_reuses=0, - ) - - actual = builder(0.0, state).toarray() - - active = np.asarray((0, 2)) - active_state = state[active] - active_offset = offset[active] - active_slopes = slopes[active] - - def active_rhs(time: float, states: np.ndarray) -> np.ndarray: - del time - states_array = np.asarray(states, dtype=float) - if states_array.ndim == 1: - return active_offset + active_slopes * states_array - return active_offset[:, None] + active_slopes[:, None] * states_array - - active_structure = csc_matrix(np.eye(2, dtype=bool)) - active_groups = group_columns(active_structure, order=0) - expected, _factor = num_jac( - active_rhs, - 0.0, - active_state, - active_rhs(0.0, active_state), - np.full(2, 1.0e-8), - None, - (active_structure, active_groups), - ) - - np.testing.assert_array_equal( - actual[np.ix_(active, active)], - expected.toarray(), - ) - diagnostics = builder.diagnostics() - self.assertGreater( - diagnostics["lastBuildFiniteDifferenceRhsEvaluationCount"], - diagnostics["remainingColorGroupCount"], - ) - for perturbed_state in evaluated_states[1:]: - self.assertEqual(perturbed_state[1], state[1]) - - def test_audit_step_respects_tiny_state_absolute_tolerance(self) -> None: - evaluator = _SwitchableLinearRhs(np.eye(2)) - builder = SparseSecantJacobian( - evaluator, - csr_matrix(np.ones((2, 2), dtype=bool)), - atol=np.asarray((1.0e-12, 1.0e-8)), - ) - state = np.zeros(2) - - step = builder._audit_step(state) - - self.assertGreater(abs(step[0]), 0.0) - self.assertLessEqual(abs(step[0]), 1.0e-12) - self.assertGreater(abs(step[1]), 0.0) - self.assertLessEqual(abs(step[1]), 1.0e-8) - - def test_optimized_finite_difference_skips_secant_observation_work(self) -> None: - matrix = np.asarray(((2.0, -1.0), (0.5, 4.0))) - evaluator = _SwitchableLinearRhs(matrix) - builder = SparseSecantJacobian( - evaluator, - csr_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - max_consecutive_reuses=0, - ) - state = np.asarray((1.0, -2.0)) - derivative = evaluator(0.0, state) - builder.observe(0.0, state, derivative) - - jacobian = builder(0.0, state) - - np.testing.assert_allclose(jacobian.toarray(), matrix, rtol=1.0e-7) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["mode"], "optimizedSparseFiniteDifference") - self.assertEqual(diagnostics["fullBuildCount"], 1) - self.assertEqual(diagnostics["secantReuseCount"], 0) - self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) - self.assertEqual(diagnostics["secantUpdateCount"], 0) - self.assertEqual(diagnostics["coloringSeed"], 0) - - def test_seed_zero_callable_matches_scipy_num_jac(self) -> None: - def nonlinear_rhs(time: float, state: np.ndarray) -> np.ndarray: - del time - return np.asarray( - ( - state[0] * state[0] + 3.0 * state[1], - np.sin(state[0]) - state[1], - ) - ) - - state = np.asarray((2.0, 1.0)) - atol = np.full(2, 1.0e-8) - structure = csc_matrix(np.ones((2, 2), dtype=bool)) - groups = group_columns(structure, order=0) - base_rhs = nonlinear_rhs(0.0, state) - - def vectorized_rhs(time: float, states: np.ndarray) -> np.ndarray: - states_array = np.asarray(states, dtype=float) - if states_array.ndim == 1: - return nonlinear_rhs(time, states_array) - return np.column_stack( - [ - nonlinear_rhs(time, states_array[:, column]) - for column in range(states_array.shape[1]) - ] - ) - - expected, _factor = num_jac( - vectorized_rhs, - 0.0, - state, - base_rhs, - atol, - None, - (structure, groups), - ) - builder = SparseSecantJacobian( - nonlinear_rhs, - structure, - atol=atol, - max_consecutive_reuses=0, - ) - - actual = builder(0.0, state) - - np.testing.assert_array_equal(actual.toarray(), expected.toarray()) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["coloringSeed"], 0) - self.assertEqual(diagnostics["baseRhsEvaluationCount"], 1) - - def test_default_coloring_stays_seed_zero_when_another_seed_is_smaller(self) -> None: - structure = csc_matrix( - np.asarray( - ( - (1, 1, 0, 0, 0), - (0, 1, 0, 0, 0), - (0, 0, 1, 0, 0), - (0, 0, 0, 1, 1), - (0, 1, 0, 0, 1), - ), - dtype=bool, - ) - ) - seed_zero_count = int(group_columns(structure, order=0).max()) + 1 - seed_54_count = int(group_columns(structure, order=54).max()) + 1 - self.assertEqual(seed_zero_count, 3) - self.assertEqual(seed_54_count, 2) - - builder = SparseSecantJacobian( - _SwitchableLinearRhs(structure.toarray().astype(float)), - structure, - atol=1.0e-8, - max_consecutive_reuses=0, - ) - - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["coloringSeed"], 0) - self.assertEqual(diagnostics["colorGroupCount"], 3) - self.assertEqual(diagnostics["defaultColorGroupCount"], 3) - - def test_exact_rows_do_not_reorder_seed_zero_perturbation_batches(self) -> None: - structure = csc_matrix(np.asarray(((1, 1), (1, 0)), dtype=bool)) - builder = SparseSecantJacobian( - _SwitchableLinearRhs(np.asarray(((1.0, 1.0), (1.0, 0.0)))), - structure, - atol=1.0e-8, - exact_rows={0: {0: 1.0, 1: 1.0}}, - max_consecutive_reuses=0, - ) - - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["coloringSeed"], 0) - self.assertEqual(diagnostics["colorGroupCount"], 2) - - def test_rejects_more_than_one_consecutive_secant_reuse(self) -> None: - with self.assertRaisesRegex( - ValueError, - "max_consecutive_reuses must be either 0 or 1", - ): - SparseSecantJacobian( - _SwitchableLinearRhs(np.eye(2)), - csr_matrix(np.eye(2, dtype=bool)), - atol=1.0e-8, - max_consecutive_reuses=2, - ) - - def test_uninformative_zero_jv_audit_forces_full_refresh(self) -> None: - evaluator = _SwitchableLinearRhs(np.zeros((2, 2))) - builder = SparseSecantJacobian( - evaluator, - csr_matrix(np.ones((2, 2), dtype=bool)), - atol=1.0e-8, - ) - state = np.asarray((1.0, 1.0)) - builder(0.0, state) - builder.observe(0.1, state, np.zeros(2)) - builder.observe(0.1, state + 1.0, np.zeros(2)) - - refreshed = builder(0.1, state + 1.0) - - np.testing.assert_array_equal(refreshed.toarray(), np.zeros((2, 2))) - diagnostics = builder.diagnostics() - self.assertEqual(diagnostics["secantReuseCount"], 0) - self.assertEqual(diagnostics["auditFailureCount"], 1) - self.assertEqual(diagnostics["fullBuildCount"], 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_stream_pressure_block_solver.py b/tests/test_stream_pressure_block_solver.py deleted file mode 100644 index a7eadb4..0000000 --- a/tests/test_stream_pressure_block_solver.py +++ /dev/null @@ -1,634 +0,0 @@ -from __future__ import annotations - -import os -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.amesim.flow.pipes import AmesimPnl0002 -from app.simulation.components.amesim.mechanical.translational import ( - AmesimLstp00a, -) -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.medium import IdealGasMedium -from app.simulation.solvers.algebraic import ( - CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, - AlgebraicSolveDiagnostics, - PressureFlowSolver, -) -from app.simulation.solvers.algebraic_blocks import ( - StreamPressureBlockSolver, - _BlockSolveAttempt, -) -from app.simulation.systems.network import SimulationNetwork - - -def _pnl0002_solver( - *, - include_unselected_island: bool = False, - executor_v2: bool = False, -) -> tuple[PressureFlowSolver, AmesimPnl0002]: - medium = IdealGasMedium() - left = Cylinder("left", medium, V=0.1, p0=500_000.0) - right = Tank("right", medium, V=0.1, p0=100_000.0) - pipe = AmesimPnl0002( - "pipe", - medium, - p0=300_000.0, - T0=300.0, - ) - network = SimulationNetwork("pnl0002-equation-blocks") - for component in (left, right, pipe): - network.add_component(component) - network.connect("left", "port_b", "pipe", "port_1") - network.connect("pipe", "port_2", "right", "port_a") - - if include_unselected_island: - isolated = Cylinder("isolated", medium, V=0.2, p0=700_000.0) - plug = AmesimPnpl01("isolated_plug") - network.add_component(isolated) - network.add_component(plug) - network.connect("isolated", "port_b", "isolated_plug", "port_1") - - for component in network.dynamic_components(): - component.refresh_thermodynamic_ports() - with patch.dict( - os.environ, - { - CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE: ( - "1" if executor_v2 else "0" - ) - }, - ): - solver = PressureFlowSolver(network) - solver.solve() - return solver, pipe - - -class StreamPressureBlockSolverTests(unittest.TestCase): - def test_compiled_v2_secondary_matches_v1_and_skips_seed_sets(self) -> None: - compiled_solver, _compiled_pipe = _pnl0002_solver(executor_v2=True) - v1_solver, _v1_pipe = _pnl0002_solver() - compiled = StreamPressureBlockSolver(compiled_solver, ("pipe",)) - v1 = StreamPressureBlockSolver(v1_solver, ("pipe",)) - - compiled.solve(scale_context=compiled_solver.scale_context()) - v1.solve(scale_context=v1_solver.scale_context()) - with patch.object( - compiled, - "_seed_selected_blocks", - wraps=compiled._seed_selected_blocks, - ) as legacy_seed, patch.object( - compiled_solver, - "_evaluate_explicit_flow_stage", - wraps=compiled_solver._evaluate_explicit_flow_stage, - ) as allocating_stage: - compiled_result = compiled.solve( - scale_context=compiled_solver.scale_context() - ) - v1.solve(scale_context=v1_solver.scale_context()) - - legacy_seed.assert_not_called() - allocating_stage.assert_not_called() - self.assertEqual( - tuple(unknown.read() for unknown in compiled_solver.unknowns), - tuple(unknown.read() for unknown in v1_solver.unknowns), - ) - self.assertIs( - compiled_result.diagnostics[0], - compiled._causal_cached_fast_diagnostics, - ) - execution = compiled.causal_execution_diagnostics() - self.assertEqual(execution["executorV2FastSolveCount"], 1) - self.assertEqual(execution["coordinateKernelFastSolveCount"], 1) - self.assertEqual( - execution["compiledAssignmentCount"], - len(compiled._selected_unknowns), - ) - self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 0) - - def test_compiled_v2_secondary_failure_restores_before_legacy_seed( - self, - ) -> None: - solver, _pipe = _pnl0002_solver(executor_v2=True) - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - block_solver.solve(scale_context=solver.scale_context()) - expected = tuple( - unknown.read() for unknown in block_solver._selected_flow_unknowns - ) - original_seed = block_solver._seed_selected_blocks - - def one_nonfinite_assignment(): - block_solver._selected_flow_unknowns[0].state.m_flow = float("nan") - return "nonFiniteCausalSecondaryFlowAssignment" - - def checked_legacy_seed(entry_values): - self.assertEqual( - tuple( - unknown.read() - for unknown in block_solver._selected_flow_unknowns - ), - expected, - ) - return original_seed(entry_values) - - with patch.object( - block_solver, - "_execute_compiled_secondary_flow_plan", - side_effect=one_nonfinite_assignment, - ), patch.object( - block_solver, - "_seed_selected_blocks", - side_effect=checked_legacy_seed, - ): - result = block_solver.solve(scale_context=solver.scale_context()) - - self.assertFalse(result.used_global_fallback) - execution = block_solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "nonFiniteCausalSecondaryFlowAssignment", - ) - self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_compiled_v2_secondary_base_error_restores_transaction(self) -> None: - class ForcedFatalError(BaseException): - pass - - solver, _pipe = _pnl0002_solver(executor_v2=True) - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - block_solver.solve(scale_context=solver.scale_context()) - expected = tuple( - unknown.read() for unknown in block_solver._selected_flow_unknowns - ) - - def broken_execution() -> None: - block_solver._selected_flow_unknowns[0].write(123_456.0) - raise ForcedFatalError("forced v2 interruption") - - with patch.object( - block_solver, - "_execute_compiled_secondary_flow_plan", - side_effect=broken_execution, - ): - with self.assertRaises(ForcedFatalError): - block_solver.solve(scale_context=solver.scale_context()) - - self.assertEqual( - tuple( - unknown.read() - for unknown in block_solver._selected_flow_unknowns - ), - expected, - ) - - def test_compiled_v2_secondary_assignment_count_drift_falls_back(self) -> None: - solver, _pipe = _pnl0002_solver(executor_v2=True) - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - block_solver.solve(scale_context=solver.scale_context()) - with patch.object( - block_solver, - "_execute_compiled_secondary_flow_plan", - return_value="causalSecondaryFlowAssignmentCountMismatch", - ): - result = block_solver.solve(scale_context=solver.scale_context()) - - self.assertFalse(result.used_global_fallback) - execution = block_solver.causal_execution_diagnostics() - self.assertEqual( - execution["disabledReason"], - "causalSecondaryFlowAssignmentCountMismatch", - ) - self.assertEqual(execution["executorV2RuntimeValidationFailureCount"], 1) - - def test_pnl0002_uses_two_blocks_with_one_shared_component(self) -> None: - solver, pipe = _pnl0002_solver() - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - - self.assertTrue(block_solver.available, block_solver.fallback_reason) - self.assertEqual(len(block_solver.blocks), 2) - self.assertTrue( - all("pipe" in block.scope_components for block in block_solver.blocks) - ) - pipe_component_evaluations = [ - evaluation - for block in block_solver.blocks - for evaluation in block.component_evaluations - if getattr(evaluation.evaluate, "__self__", None) is pipe - ] - self.assertEqual(len(pipe_component_evaluations), 2) - self.assertTrue( - {unknown.id for unknown in block_solver.blocks[0].unknowns}.isdisjoint( - unknown.id for unknown in block_solver.blocks[1].unknowns - ) - ) - - result = block_solver.solve(scale_context=solver.scale_context()) - - self.assertFalse(result.used_global_fallback) - self.assertEqual(len(result.diagnostics), 1) - self.assertAlmostEqual( - pipe.port_1.m_flow, - pipe.port_mass_flow( - pipe.port_1.p, - pipe.properties().p, - pipe.properties().T, - port_name="port_1", - ), - places=12, - ) - self.assertAlmostEqual( - pipe.port_2.m_flow, - pipe.port_mass_flow( - pipe.port_2.p, - pipe.properties().p, - pipe.properties().T, - port_name="port_2", - ), - places=12, - ) - - def test_selected_block_seeding_preserves_every_unselected_unknown(self) -> None: - solver, _pipe = _pnl0002_solver(include_unselected_island=True) - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - unselected = tuple( - unknown - for unknown in solver.unknowns - if unknown.component in {"isolated", "isolated_plug"} - ) - for index, unknown in enumerate(unselected, start=1): - unknown.write(10_000.0 * index) - expected = tuple(unknown.read() for unknown in unselected) - - result = block_solver.solve(scale_context=solver.scale_context()) - - self.assertFalse(result.used_global_fallback) - self.assertEqual( - tuple(unknown.read() for unknown in unselected), - expected, - ) - - def test_causal_secondary_pressure_mutation_fuses_to_verified_path(self) -> None: - solver, _pipe = _pnl0002_solver() - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - first = block_solver.solve(scale_context=solver.scale_context()) - self.assertFalse(first.used_global_fallback) - expected_pressures = tuple( - unknown.read() - for unknown in block_solver._selected_unknowns - if unknown.variable == "p" - ) - original_seed = block_solver._seed_selected_blocks - mutated_pressure = next( - unknown - for unknown in block_solver._selected_unknowns - if unknown.variable == "p" - ) - - def seed_then_mutate(entry_values): - seeded = original_seed(entry_values) - mutated_pressure.write(mutated_pressure.read() + 10_000.0) - return seeded - - with patch.object( - block_solver, - "_seed_selected_blocks", - side_effect=seed_then_mutate, - ): - result = block_solver.solve(scale_context=solver.scale_context()) - - self.assertFalse(result.used_global_fallback) - self.assertTrue(result.diagnostics[0].residual_verified_this_solve) - actual_pressures = tuple( - unknown.read() - for unknown in block_solver._selected_unknowns - if unknown.variable == "p" - ) - for expected, actual in zip(expected_pressures, actual_pressures): - self.assertAlmostEqual( - actual, - expected, - delta=1.0e-12 * max(abs(expected), 1.0), - ) - execution = block_solver.causal_execution_diagnostics() - self.assertFalse(execution["enabled"]) - self.assertEqual( - execution["disabledReason"], - "causalSecondaryRuntimeGateFailed", - ) - self.assertEqual(execution["legacyFallbackCount"], 1) - - def test_sparse_block_reports_actual_residual_evaluations(self) -> None: - solver, pipe = _pnl0002_solver() - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - pipe.port_1.m_flow += 0.01 - - with patch.object( - block_solver, - "_seed_selected_blocks", - return_value=None, - ): - result = block_solver.solve(scale_context=solver.scale_context()) - - sparse = result.diagnostics[0] - self.assertEqual(sparse.jacobian_mode, "blockSparse") - self.assertGreater(sparse.evaluations, 0) - self.assertGreater(sparse.residual_evaluations, sparse.evaluations) - self.assertFalse(sparse.dense_fallback_used) - - def test_failed_sparse_block_restores_its_original_unknowns(self) -> None: - solver, pipe = _pnl0002_solver() - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - block = block_solver.blocks[0] - pipe.port_1.m_flow += 0.01 - expected = tuple(unknown.read() for unknown in block.unknowns) - - def failed_sparse(_fun, x0, **_kwargs): - return SimpleNamespace( - x=x0 + 123.0, - success=False, - status=-1, - message="forced sparse block failure", - nfev=1, - ) - - with patch("scipy.optimize.least_squares", side_effect=failed_sparse): - attempt = block_solver._solve_block( - block, - solver.scale_context(), - ) - - self.assertIsNone(attempt.diagnostics) - self.assertEqual(attempt.optimizer_evaluations, 1) - self.assertEqual( - tuple(unknown.read() for unknown in block.unknowns), - expected, - ) - - def test_block_failure_restores_full_snapshot_before_global_fallback(self) -> None: - solver, pipe = _pnl0002_solver(include_unselected_island=True) - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - pipe.port_1.m_flow += 0.01 - solver.network.components["isolated_plug"].port_1.p = 12_345.0 - expected = tuple(unknown.read() for unknown in solver.unknowns) - original_global_solve = solver.solve - - def checked_global_solve(*args, **kwargs): - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - expected, - ) - return original_global_solve(*args, **kwargs) - - def failed_block(block, _scale_context, **_kwargs): - for unknown in block.unknowns: - unknown.write(unknown.read() + 321.0) - return _BlockSolveAttempt( - diagnostics=None, - optimizer_evaluations=2, - residual_evaluations=7, - failure_reason="blockResidualNotConverged", - ) - - with patch.object( - block_solver, - "_seed_selected_blocks", - return_value=None, - ), patch.object( - block_solver, - "_solve_block", - side_effect=failed_block, - ), patch.object( - solver, - "solve", - side_effect=checked_global_solve, - ) as global_solve: - result = block_solver.solve(scale_context=solver.scale_context()) - - global_solve.assert_called_once() - self.assertTrue(result.used_global_fallback) - self.assertEqual(len(result.diagnostics), 1) - fallback_diagnostics = result.diagnostics[0] - self.assertIn( - fallback_diagnostics.jacobian_mode, - { - "seeded", - "sparse", - "dense", - "sparseThenDense", - "blockSparse", - }, - ) - self.assertGreaterEqual( - fallback_diagnostics.residual_evaluations, - fallback_diagnostics.evaluations, - ) - self.assertGreaterEqual(fallback_diagnostics.evaluations, 2) - self.assertGreaterEqual(fallback_diagnostics.residual_evaluations, 7) - self.assertTrue(fallback_diagnostics.block_fallback_used) - self.assertIn( - "blockResidualNotConverged", - fallback_diagnostics.block_fallback_reason or "", - ) - - def test_seed_exception_restores_snapshot_before_global_fallback(self) -> None: - solver, _pipe = _pnl0002_solver(include_unselected_island=True) - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - expected = tuple(unknown.read() for unknown in solver.unknowns) - original_global_solve = solver.solve - - def broken_seed(_entry_values) -> None: - solver.unknowns[0].write(solver.unknowns[0].read() + 123_456.0) - raise ValueError("forced seed failure") - - def checked_global_solve(*args, **kwargs): - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - expected, - ) - return original_global_solve(*args, **kwargs) - - with patch.object( - block_solver, - "_seed_selected_blocks", - side_effect=broken_seed, - ), patch.object( - solver, - "solve", - side_effect=checked_global_solve, - ) as global_solve: - result = block_solver.solve(scale_context=solver.scale_context()) - - global_solve.assert_called_once() - self.assertTrue(result.used_global_fallback) - - def test_failed_global_fallback_does_not_leak_candidate_state(self) -> None: - solver, _pipe = _pnl0002_solver(include_unselected_island=True) - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - expected = tuple(unknown.read() for unknown in solver.unknowns) - - def failed_block(block, _scale_context, **_kwargs): - for unknown in block.unknowns: - unknown.write(unknown.read() + 123.0) - return _BlockSolveAttempt( - diagnostics=None, - optimizer_evaluations=1, - residual_evaluations=4, - failure_reason="blockResidualNotConverged", - ) - - def failed_global_solve(*_args, **_kwargs): - for unknown in solver.unknowns: - unknown.write(unknown.read() - 456.0) - raise RuntimeError("forced global fallback failure") - - with patch.object( - block_solver, - "_seed_selected_blocks", - return_value=None, - ), patch.object( - block_solver, - "_seeded_diagnostics", - return_value=None, - ), patch.object( - block_solver, - "_solve_block", - side_effect=failed_block, - ), patch.object( - solver, - "solve", - side_effect=failed_global_solve, - ): - with self.assertRaisesRegex( - RuntimeError, - "forced global fallback failure", - ): - block_solver.solve(scale_context=solver.scale_context()) - - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - expected, - ) - - def test_local_to_global_fallback_aggregates_attempt_chain_once(self) -> None: - solver, _pipe = _pnl0002_solver() - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - accepted_global = AlgebraicSolveDiagnostics( - success=True, - message="accepted global result", - evaluations=3, - pressure_scale=400_000.0, - flow_scale=0.01, - max_scaled_residual=0.25, - max_raw_residual=5.0, - residual_evaluations=11, - jacobian_mode="dense", - ) - failed_local = _BlockSolveAttempt( - diagnostics=None, - optimizer_evaluations=2, - residual_evaluations=7, - failure_reason="blockResidualNotConverged", - ) - - with patch.object( - block_solver, - "_seeded_diagnostics", - return_value=None, - ), patch.object( - block_solver, - "_solve_block", - return_value=failed_local, - ), patch.object( - solver, - "solve", - return_value=accepted_global, - ) as global_solve: - result = block_solver.solve(scale_context=solver.scale_context()) - - global_solve.assert_called_once() - self.assertTrue(result.used_global_fallback) - self.assertEqual(len(result.diagnostics), 1) - aggregate = result.diagnostics[0] - self.assertEqual(aggregate.evaluations, 5) - self.assertEqual(aggregate.residual_evaluations, 18) - self.assertEqual(aggregate.jacobian_mode, "dense") - self.assertEqual(aggregate.max_scaled_residual, 0.25) - self.assertTrue(aggregate.block_fallback_used) - self.assertEqual( - aggregate.block_fallback_reason, - "blockResidualNotConverged", - ) - - def test_failed_global_fallback_restores_lstp_causal_cache_for_base_errors( - self, - ) -> None: - class ForcedFatalError(BaseException): - pass - - for error_type in (MemoryError, ForcedFatalError): - with self.subTest(error_type=error_type.__name__): - solver, _pipe = _pnl0002_solver() - block_solver = StreamPressureBlockSolver(solver, ("pipe",)) - block_solver.fallback_reason = "forcedUntrustedStructure" - contact = AmesimLstp00a( - "contact", - IdealGasMedium(), - gap0=0.0, - kcont=1.0e6, - rcont=0.0, - Pdis=1.0e-6, - discContactOption=1.0, - ) - contact.port_1.x = 1.25 - contact.port_2.x = 1.0 - contact.port_1.v = 0.5 - contact.port_2.v = -0.25 - contact.set_causal_contact(penetration=0.25, force=12.0) - solver._causal_contact_components = (contact,) - expected_unknowns = tuple( - unknown.read() for unknown in solver.unknowns - ) - causal_names = tuple( - name - for name in vars(contact) - if name.startswith("_causal_") - ) - expected_causal = tuple( - getattr(contact, name) for name in causal_names - ) - expected_last = solver.last_diagnostics - - def failed_global_solve(*_args, **_kwargs): - solver.unknowns[0].write( - solver.unknowns[0].read() + 123_456.0 - ) - contact.clear_causal_contact() - solver.last_diagnostics = None - raise error_type("forced global fallback failure") - - with patch.object( - solver, - "solve", - side_effect=failed_global_solve, - ): - with self.assertRaises(error_type): - block_solver.solve(scale_context=solver.scale_context()) - - self.assertEqual( - tuple(unknown.read() for unknown in solver.unknowns), - expected_unknowns, - ) - self.assertEqual( - tuple(getattr(contact, name) for name in causal_names), - expected_causal, - ) - self.assertIs(solver.last_diagnostics, expected_last) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_stream_resolver_execution_plan.py b/tests/test_stream_resolver_execution_plan.py deleted file mode 100644 index 24d61dd..0000000 --- a/tests/test_stream_resolver_execution_plan.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -import unittest - -from app.simulation.core.base import AlgebraicComponent, DynamicComponent -from app.simulation.core.ports import PortDefinition -from app.simulation.solvers.stream import StreamResolver -from app.simulation.systems.network import SimulationNetwork - - -class _CountingDynamicAnchor(DynamicComponent): - PORTS = (PortDefinition.pneumatic("port"),) - - def __init__( - self, - name: str, - *, - enthalpy: float, - temperature_reference_h: float, - ) -> None: - super().__init__(name) - self.enthalpy = enthalpy - self.temperature_reference_h = temperature_reference_h - self.refresh_count = 0 - self.port = self.register_declared_port("port") - self.port.h_outflow = -1.0 - - def make_ports_current(self) -> None: - self.port.h_outflow = self.enthalpy - - def get_state_vector(self) -> list[float]: - return [0.0, 0.0] - - def set_state_vector(self, values: list[float]) -> None: - if len(values) != self.state_size: - raise ValueError("Unexpected test state size.") - - def refresh_thermodynamic_ports(self) -> None: - self.refresh_count += 1 - self.make_ports_current() - - def state_derivative_from_ports( - self, - connected_h: Mapping[str, float], - ) -> list[float]: - return [0.0, 0.0] - - -class _PassThrough(AlgebraicComponent): - PORTS = ( - PortDefinition.pneumatic("left"), - PortDefinition.pneumatic("right"), - ) - - def __init__(self, name: str, update_log: list[str]) -> None: - super().__init__(name) - self.left = self.register_declared_port("left") - self.right = self.register_declared_port("right") - self.update_log = update_log - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - self.update_log.append(self.name) - self.left.h_outflow = connected_h["right"] - self.right.h_outflow = connected_h["left"] - - -class _ReferenceAwarePassThrough(_PassThrough): - def __init__( - self, - name: str, - update_log: list[str], - reference_log: list[str], - ) -> None: - super().__init__(name, update_log) - self.reference_log = reference_log - self.flow_temperature_references: dict[str, float] = {} - - def update_flow_temperature_references( - self, - connected_h: Mapping[str, float], - ) -> None: - self.reference_log.append(self.name) - self.flow_temperature_references = dict(connected_h) - - -def _build_chain() -> tuple[ - SimulationNetwork, - _CountingDynamicAnchor, - _PassThrough, - _PassThrough, - _CountingDynamicAnchor, - list[str], -]: - update_log: list[str] = [] - left = _CountingDynamicAnchor( - "left_anchor", - enthalpy=100.0, - temperature_reference_h=1_100.0, - ) - first = _PassThrough("first", update_log) - second = _PassThrough("second", update_log) - right = _CountingDynamicAnchor( - "right_anchor", - enthalpy=400.0, - temperature_reference_h=1_400.0, - ) - network = SimulationNetwork("stream-chain") - for component in (left, first, second, right): - network.add_component(component) - network.connect("left_anchor", "port", "first", "left") - network.connect("first", "right", "second", "left") - network.connect("second", "right", "right_anchor", "port") - return network, left, first, second, right, update_log - - -class StreamResolverExecutionPlanTests(unittest.TestCase): - def test_standalone_solve_refreshes_each_dynamic_exactly_once(self) -> None: - network, left, _first, _second, right, _update_log = _build_chain() - - diagnostics, _connected = StreamResolver(network).solve() - - self.assertTrue(diagnostics.converged) - self.assertEqual(left.refresh_count, 1) - self.assertEqual(right.refresh_count, 1) - - def test_current_dynamic_ports_skip_refresh(self) -> None: - network, left, _first, _second, right, _update_log = _build_chain() - left.make_ports_current() - right.make_ports_current() - - diagnostics, connected = StreamResolver(network).solve( - dynamic_ports_are_current=True - ) - - self.assertTrue(diagnostics.converged) - self.assertEqual(left.refresh_count, 0) - self.assertEqual(right.refresh_count, 0) - self.assertEqual(connected["first"], {"left": 100.0, "right": 400.0}) - - def test_multiple_iterations_do_not_repeat_dynamic_refresh(self) -> None: - network, left, _first, _second, right, update_log = _build_chain() - - diagnostics, _connected = StreamResolver(network).solve() - - self.assertGreater(diagnostics.iterations, 1) - self.assertEqual(left.refresh_count, 1) - self.assertEqual(right.refresh_count, 1) - self.assertEqual( - update_log, - ["first", "second"] * diagnostics.iterations, - ) - - def test_non_dynamic_flow_reference_hook_does_not_repeat_stream_update( - self, - ) -> None: - update_log: list[str] = [] - reference_log: list[str] = [] - left = _CountingDynamicAnchor( - "left_anchor", - enthalpy=100.0, - temperature_reference_h=1_100.0, - ) - middle = _ReferenceAwarePassThrough( - "middle", - update_log, - reference_log, - ) - right = _CountingDynamicAnchor( - "right_anchor", - enthalpy=400.0, - temperature_reference_h=1_400.0, - ) - network = SimulationNetwork("flow-temperature-reference") - for component in (left, middle, right): - network.add_component(component) - network.connect("left_anchor", "port", "middle", "left") - network.connect("middle", "right", "right_anchor", "port") - resolver = StreamResolver(network) - - diagnostics, _connected = resolver.solve() - stream_updates_before = tuple(update_log) - outflows_before = (middle.left.h_outflow, middle.right.h_outflow) - resolver.refresh_flow_temperature_references() - - self.assertEqual( - stream_updates_before, - ("middle",) * diagnostics.iterations, - ) - self.assertEqual(tuple(update_log), stream_updates_before) - self.assertEqual(reference_log, ["middle"]) - self.assertEqual( - middle.flow_temperature_references, - {"left": 1_100.0, "right": 1_400.0}, - ) - self.assertEqual( - (middle.left.h_outflow, middle.right.h_outflow), - outflows_before, - ) - - def test_precompiled_bindings_preserve_outputs_and_references(self) -> None: - default_network, default_left, default_first, default_second, default_right, _ = ( - _build_chain() - ) - current_network, current_left, current_first, current_second, current_right, _ = ( - _build_chain() - ) - current_left.make_ports_current() - current_right.make_ports_current() - default_resolver = StreamResolver(default_network) - current_resolver = StreamResolver(current_network) - - default_diagnostics, default_connected = default_resolver.solve() - current_diagnostics, current_connected = current_resolver.solve( - dynamic_ports_are_current=True - ) - - self.assertEqual(default_diagnostics, current_diagnostics) - self.assertEqual(default_connected, current_connected) - self.assertEqual( - ( - default_left.port.h_outflow, - default_first.left.h_outflow, - default_first.right.h_outflow, - default_second.left.h_outflow, - default_second.right.h_outflow, - default_right.port.h_outflow, - ), - ( - current_left.port.h_outflow, - current_first.left.h_outflow, - current_first.right.h_outflow, - current_second.left.h_outflow, - current_second.right.h_outflow, - current_right.port.h_outflow, - ), - ) - references = default_resolver.connected_temperature_reference_enthalpies() - self.assertEqual(references["first"]["left"], 1_100.0) - self.assertEqual(references["second"]["right"], 1_400.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_supported_piston_tangent.py b/tests/test_supported_piston_tangent.py deleted file mode 100644 index dde02cc..0000000 --- a/tests/test_supported_piston_tangent.py +++ /dev/null @@ -1,289 +0,0 @@ -from pathlib import Path -import os -import unittest -from unittest.mock import patch - -import numpy as np -from scipy.optimize._numdiff import group_columns - -from app.main import compile_system_xml_network -from app.simulation.solvers.jacobian import ( - ExactColumnsUnavailable, - SparseSecantJacobian, -) -import app.simulation.solvers.tangent as tangent_module -from app.simulation.solvers.tangent import ( - compile_supported_piston_tangent_provider, -) -from app.simulation.systems.generic import GenericFluidSystem -from app.simulation.systems.network import Endpoint -from app.system_xml import validate_system_xml_document - - -TARGET_XML = Path("tests/data/test-mql-8.xml") -EXPECTED_COLUMNS = tuple(range(104, 120)) -EXPECTED_BRANCH_NAMES = ( - ( - "amesim_mecmas21_1", - "amesim_pnrp17_1", - "amesim_pnch012_15", - "amesim_pnl0001_13", - "amesim_lstp00a_1", - ), - ( - "amesim_mecmas21_2", - "amesim_pnrp17_2", - "amesim_pnch012_14", - "amesim_pnl0001_14", - "amesim_lstp00a_2", - ), - ( - "amesim_mecmas21_3", - "amesim_pnrp17_3", - "amesim_pnch012_13", - "amesim_pnl0001_15", - "amesim_lstp00a_3", - ), - ( - "amesim_mecmas21_4", - "amesim_pnrp17_4", - "amesim_pnch012_12", - "amesim_pnl0001_16", - "amesim_lstp00a_4", - ), - ( - "amesim_mecmas21_5", - "amesim_pnrp17_5", - "amesim_pnch012_11", - "amesim_pnl0001_17", - "amesim_lstp00a_5", - ), - ( - "amesim_mecmas21_6", - "amesim_pnrp17_6", - "amesim_pnch012_10", - "amesim_pnl0001_18", - "amesim_lstp00a_6", - ), - ( - "amesim_mecmas21_7", - "amesim_pnrp17_7", - "amesim_pnch012_9", - "amesim_pnl0001_19", - "amesim_lstp00a_7", - ), - ( - "amesim_mecmas21_8", - "amesim_pnrp17_8", - "amesim_pnch012_8", - "amesim_pnl0001_20", - "amesim_lstp00a_8", - ), -) - - -class SupportedPistonTangentCompilerTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - report = validate_system_xml_document(TARGET_XML.read_bytes()) - assert report.valid and report.document is not None - with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): - cls.system = GenericFluidSystem( - compile_system_xml_network(report.document) - ) - cls.initial_state = np.asarray( - cls.system.consistent_initial_state_vector(0.0), - dtype=float, - ) - - def setUp(self) -> None: - self.system.mechanical_state_reducer.reset_constraint_modes() - self.system.apply_state_vector(self.initial_state.tolist()) - - def _closed_primal( - self, - state: np.ndarray, - ) -> tuple[dict[str, dict[str, float]], np.ndarray]: - self.system.apply_state_vector(state.tolist()) - connected_h = self.system._close_current_state(0.0) - derivative = np.asarray( - self.system._state_derivatives(connected_h), - dtype=float, - ) - return connected_h, derivative - - def test_discovers_all_branches_without_legacy_names_or_offsets(self) -> None: - with patch.object( - tangent_module, - "_TARGET_BRANCH_NAMES", - (("not", "a", "real", "branch", "name"),), - ): - compilation = compile_supported_piston_tangent_provider( - self.system - ) - - self.assertTrue(compilation.eligible, compilation.reason) - self.assertEqual(compilation.columns, EXPECTED_COLUMNS) - self.assertEqual(compilation.reached_assignment_count, 84) - provider = compilation.provider - assert provider is not None - self.assertEqual(len(provider.branches), 8) - self.assertEqual( - tuple( - ( - branch.mass.name, - branch.piston.name, - branch.chamber.name, - branch.pipe.name, - branch.contact.name, - ) - for branch in provider.branches - ), - EXPECTED_BRANCH_NAMES, - ) - self.assertEqual( - {branch.chamber_connection_port for branch in provider.branches}, - {"port_2"}, - ) - - def test_exact_columns_reduce_seed_zero_pattern_to_36_colors(self) -> None: - compilation = compile_supported_piston_tangent_provider(self.system) - self.assertTrue(compilation.eligible, compilation.reason) - provider = compilation.provider - assert provider is not None - - pattern = self.system.jacobian_sparsity().tocsc().astype(bool) - columns_only = pattern.tolil(copy=True) - columns_only[:, list(compilation.columns)] = False - columns_only = columns_only.tocsc() - columns_only.eliminate_zeros() - active_columns = np.flatnonzero( - np.asarray(columns_only.getnnz(axis=0)).reshape(-1) > 0 - ) - groups = group_columns( - columns_only[:, active_columns], - order=0, - ) - - self.assertEqual(pattern.nnz, 3280) - self.assertEqual(columns_only.nnz, 2448) - self.assertEqual(len(active_columns), 116) - self.assertEqual(int(groups.max(initial=-1)) + 1, 36) - - jacobian = SparseSecantJacobian( - lambda _time, state: np.zeros_like(state), - pattern, - 1.0e-8, - exact_rows=self.system._exact_ode_jacobian_rows(), - exact_columns=(compilation.columns, provider), - max_consecutive_reuses=0, - ) - diagnostics = jacobian.diagnostics() - self.assertEqual(diagnostics["originalColorGroupCount"], 52) - self.assertEqual(diagnostics["remainingColorGroupCount"], 36) - self.assertEqual(diagnostics["exactColumnCount"], 16) - self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 116) - self.assertEqual( - jacobian._remaining_finite_difference_sparsity.nnz, - 2160, - ) - - def test_partial_unsupported_branch_fails_with_stable_reason(self) -> None: - piston_endpoint = Endpoint("amesim_pnrp17_1", "port_5") - connection_index, connection = next( - (index, connection) - for index, connection in enumerate(self.system.network.connections) - if piston_endpoint in connection.endpoints - ) - del self.system.network.connections[connection_index] - try: - compilation = compile_supported_piston_tangent_provider( - self.system - ) - finally: - self.system.network.connections.insert( - connection_index, - connection, - ) - - self.assertFalse(compilation.eligible) - self.assertEqual( - compilation.reason, - "unsupportedPistonBranchTopology:contact", - ) - - def test_initial_contact_boundary_requests_full_numeric_columns(self) -> None: - compilation = compile_supported_piston_tangent_provider(self.system) - self.assertTrue(compilation.eligible, compilation.reason) - provider = compilation.provider - assert provider is not None - connected_h, _derivative = self._closed_primal( - self.initial_state.copy() - ) - provider.request_primal_capture() - provider.record_primal( - 0.0, - self.initial_state, - connected_h, - ) - - with self.assertRaises(ExactColumnsUnavailable) as captured: - provider( - 0.0, - self.initial_state.copy(), - compilation.columns, - ) - self.assertEqual( - captured.exception.reason, - "contactMode:contact_mode_boundary", - ) - - def test_smooth_sixteen_columns_match_centered_full_rhs(self) -> None: - compilation = compile_supported_piston_tangent_provider(self.system) - self.assertTrue(compilation.eligible, compilation.reason) - provider = compilation.provider - assert provider is not None - - state = self.initial_state.copy() - for branch in provider.branches: - chamber_offset, chamber_size = provider.state_offsets[ - branch.chamber.name - ] - self.assertEqual(chamber_size, 2) - state[chamber_offset] *= 1.01 - state[branch.position_index] -= 1.0e-3 - - connected_h, _base = self._closed_primal(state) - provider.request_primal_capture() - provider.record_primal(0.0, state, connected_h) - exact = provider(0.0, state.copy(), compilation.columns) - - numerical = np.empty_like(exact) - for local_column, state_index in enumerate(compilation.columns): - step = 1.0e-7 * max(abs(state[state_index]), 1.0) - lower = state.copy() - upper = state.copy() - lower[state_index] -= step - upper[state_index] += step - lower_rhs = np.asarray( - self.system.rhs(0.0, lower.tolist()), - dtype=float, - ) - upper_rhs = np.asarray( - self.system.rhs(0.0, upper.tolist()), - dtype=float, - ) - numerical[:, local_column] = ( - upper_rhs - lower_rhs - ) / (2.0 * step) - - np.testing.assert_allclose( - exact, - numerical, - rtol=3.0e-6, - atol=2.0e-5, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_8_regression.py b/tests/test_test_mql_8_regression.py deleted file mode 100644 index 18e0e43..0000000 --- a/tests/test_test_mql_8_regression.py +++ /dev/null @@ -1,387 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import os -from pathlib import Path -import unittest - -from app.main import compile_system_xml_network -from app.simulation.benchmark_regression import ( - DEFAULT_MANIFEST_PATH, - evaluate_regression_golden, - load_regression_golden, - load_regression_manifest, - run_regression_suite, - source_simulation_config, -) -from app.simulation.systems.generic import GenericFluidSystem -from app.system_xml import validate_system_xml_document - - -LONG_RUN_ENVIRONMENT = "RUN_TEST_MQL_8_LONG_REGRESSION" - - -class TestMql8StaticRegressionTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH) - cls.source_path = Path(cls.manifest["_sourcePath"]) - cls.source_payload = cls.source_path.read_bytes() - cls.validation = validate_system_xml_document(cls.source_payload) - - def test_authoritative_xml_hash_and_simulation_settings_are_fixed(self) -> None: - source = self.manifest["source"] - - self.assertEqual( - hashlib.sha256(self.source_payload).hexdigest(), source["sha256"] - ) - self.assertEqual(len(self.source_payload), source["bytes"]) - self.assertEqual( - source_simulation_config(self.source_payload), source["simulation"] - ) - self.assertEqual(self.source_path.name, "test-mql-8.xml") - - companion = source["companionProject"] - companion_path = Path(self.manifest["_companionPath"]) - companion_payload = companion_path.read_bytes() - companion_document = json.loads(companion_payload) - self.assertEqual(companion_path.name, "test-mql-8.json") - self.assertEqual( - hashlib.sha256(companion_payload).hexdigest(), companion["sha256"] - ) - self.assertEqual(len(companion_payload), companion["bytes"]) - self.assertFalse(companion["executionInput"]) - self.assertEqual( - companion_document["simulation"], - { - "t_start": source["simulation"]["tStart"], - "t_stop": source["simulation"]["tStop"], - "step": source["simulation"]["sampleStep"], - "max_step": source["simulation"]["maxStep"], - "method": source["simulation"]["method"], - }, - ) - - def test_authoritative_xml_validates_and_matches_structure_snapshot(self) -> None: - self.assertTrue(self.validation.valid, self.validation.as_dict()) - assert self.validation.document is not None - network = compile_system_xml_network(self.validation.document) - system = GenericFluidSystem(network) - expected = self.manifest["structure"] - pressure_flow = network.pressure_flow_structure_dict() - causal_execution = system.pressure_flow_solver.causal_execution_diagnostics() - jacobian = system.jacobian_sparsity_diagnostics() - - self.assertEqual(len(network.components), expected["componentCount"]) - self.assertEqual(len(network.connections), expected["connectionCount"]) - self.assertEqual( - len(network.dynamic_components()), expected["dynamicComponentCount"] - ) - self.assertEqual( - sum(component.state_size for component in network.dynamic_components()), - expected["stateCount"], - ) - self.assertEqual( - len(network.result_variable_metadata()), expected["resultVariableCount"] - ) - self.assertEqual( - pressure_flow["unknownCount"], expected["pressureFlowUnknownCount"] - ) - self.assertEqual( - pressure_flow["equationCount"], expected["pressureFlowEquationCount"] - ) - self.assertEqual(pressure_flow["isSquare"], expected["pressureFlowIsSquare"]) - for key in ( - "logicalEffortCoordinateCount", - "eliminatedEffortAliasCount", - "canonicalCoordinateCount", - "compatibilityScatterCount", - ): - self.assertEqual(causal_execution[key], expected[key]) - self.assertEqual( - jacobian["nonzeroCount"], expected["jacobianNonzeroCount"] - ) - self.assertEqual( - jacobian["colorGroupCount"], expected["jacobianColorGroupCount"] - ) - self.assertEqual( - system.mechanical_state_reducer.has_state_events, - expected["hasMechanicalStateEvents"], - ) - - def test_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( - compile_system_xml_network(self.validation.document) - ) - - for case_id in self.manifest["sequence"]: - variant = self.manifest["variants"][case_id] - actual = system.signal_resolver.event_times( - 0.0, float(variant["stopTime"]) - ) - self.assertEqual( - actual, - tuple(variant["expectedSignalEventTimes"]), - case_id, - ) - - def test_historical_v2_golden_replays_its_source_report(self) -> None: - repository_root = Path(self.manifest["_repositoryRoot"]) - golden_path = ( - repository_root - / "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json" - ) - golden = load_regression_golden( - golden_path, - expected_sha256="99fa7b3631a89f59f86551847175f3701a1567d8d0bd10d87545734f17515d72", - repository_root=repository_root, - ) - source_report = golden["provenance"]["sourceReport"] - report = json.loads( - (repository_root / source_report["path"]).read_text(encoding="utf-8") - ) - case = next(item for item in report["cases"] if item["caseId"] == "0.2s") - audit = evaluate_regression_golden(case["worker"]["summary"], golden) - - self.assertNotEqual( - golden["sourceXmlSha256"], - self.manifest["source"]["sha256"], - ) - self.assertTrue(audit["passed"], audit) - self.assertEqual(audit["comparedValueCount"], 402) - self.assertEqual(audit["maxAbsoluteError"], 0.0) - self.assertEqual(audit["maxToleranceRatio"], 0.0) - - def test_approved_production_golden_replays_its_source_report(self) -> None: - repository_root = Path(self.manifest["_repositoryRoot"]) - reference = self.manifest["variants"]["0.2s"]["goldens"]["production"] - golden = load_regression_golden( - repository_root / reference["path"], - expected_sha256=reference["sha256"], - repository_root=repository_root, - ) - source_report = golden["provenance"]["sourceReport"] - report = json.loads( - (repository_root / source_report["path"]).read_text(encoding="utf-8") - ) - case = next(item for item in report["cases"] if item["caseId"] == "0.2s") - audit = evaluate_regression_golden(case["worker"]["summary"], golden) - - self.assertTrue(audit["passed"], audit) - self.assertEqual(audit["comparedValueCount"], 426) - self.assertEqual(audit["maxAbsoluteError"], 0.0) - self.assertEqual(audit["maxToleranceRatio"], 0.0) - - def test_historical_v2_extension_decision_remains_auditable(self) -> None: - repository_root = Path(self.manifest["_repositoryRoot"]) - runs = repository_root / "tests/baselines/simulation/test_mql_8/runs" - decision = json.loads( - (runs / "2026-08-17-production-v2-extension-decision.json").read_text( - encoding="utf-8" - ) - ) - report_path = runs / decision["sourceReport"] - report_payload = report_path.read_bytes() - report = json.loads(report_payload) - source_case = next( - item for item in report["cases"] if item["caseId"] == "0.2s" - ) - first_decision = decision["decisions"][0] - - self.assertEqual( - decision["sourceXmlSha256"], - "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3", - ) - self.assertNotEqual( - decision["sourceXmlSha256"], self.manifest["source"]["sha256"] - ) - self.assertEqual( - hashlib.sha256(report_payload).hexdigest(), - decision["sourceReportSha256"], - ) - self.assertTrue(source_case["acceptance"]["passed"]) - self.assertEqual( - decision["observedCase"]["workerWallSeconds"], - source_case["worker"]["wallSeconds"], - ) - expected_prediction = ( - source_case["worker"]["wallSeconds"] - * self.manifest["variants"]["1s"]["stopTime"] - / source_case["stopTime"] - * self.manifest["execution"]["predictionSafetyFactor"] - ) - self.assertAlmostEqual( - first_decision["predictedWallSeconds"], expected_prediction - ) - self.assertGreater( - first_decision["predictedWallSeconds"], - first_decision["softTimeoutSeconds"], - ) - self.assertEqual(first_decision["outcome"], "deferred") - self.assertTrue(decision["simulationWasNotStartedForDeferredCases"]) - - def test_extension_decision_is_bound_to_the_current_report_and_budget(self) -> None: - repository_root = Path(self.manifest["_repositoryRoot"]) - evidence = self.manifest["acceptanceEvidence"] - decision_reference = evidence["extensionDecision"] - decision_path = repository_root / decision_reference["path"] - decision_payload = decision_path.read_bytes() - decision = json.loads(decision_payload) - report_reference = evidence["production0.2ApprovedReplay"] - report_path = repository_root / report_reference["path"] - report_payload = report_path.read_bytes() - report = json.loads(report_payload) - source_case = next( - item for item in report["cases"] if item["caseId"] == "0.2s" - ) - first_decision = decision["decisions"][0] - - self.assertEqual( - decision["sourceXmlSha256"], self.manifest["source"]["sha256"] - ) - self.assertEqual(decision["sourceReport"], report_path.name) - self.assertEqual(len(report_payload), report_reference["bytes"]) - self.assertEqual( - hashlib.sha256(report_payload).hexdigest(), report_reference["sha256"] - ) - self.assertEqual(len(decision_payload), decision_reference["bytes"]) - self.assertEqual( - hashlib.sha256(decision_payload).hexdigest(), - decision_reference["sha256"], - ) - self.assertEqual( - hashlib.sha256(report_payload).hexdigest(), - decision["sourceReportSha256"], - ) - self.assertTrue(source_case["acceptance"]["passed"]) - self.assertEqual( - decision["observedCase"]["workerWallSeconds"], - source_case["worker"]["wallSeconds"], - ) - expected_prediction = ( - source_case["worker"]["wallSeconds"] - * self.manifest["variants"]["1s"]["stopTime"] - / source_case["stopTime"] - * self.manifest["execution"]["predictionSafetyFactor"] - ) - self.assertAlmostEqual( - first_decision["predictedWallSeconds"], expected_prediction - ) - self.assertLessEqual( - first_decision["predictedWallSeconds"], - first_decision["softTimeoutSeconds"], - ) - self.assertEqual(first_decision["outcome"], "eligible") - self.assertEqual(first_decision["reason"], "predictedWallWithinSoftBudget") - self.assertEqual( - [item["outcome"] for item in decision["decisions"]], - ["eligible", "notEvaluated", "notEvaluated"], - ) - self.assertTrue(decision["extensionWasNotStarted"]) - self.assertEqual( - decision["scopeReason"], - "OPT-00 closes at the approved production 0.2 s gate; longer progressive horizons remain separately staged work.", - ) - - def test_periodic_main_lane_exercises_the_approved_production_golden(self) -> None: - workflow = ( - Path(self.manifest["_repositoryRoot"]) - / ".github/workflows/solver-regression.yml" - ).read_text(encoding="utf-8") - - self.assertIn("default: production", workflow) - self.assertIn("inputs.lane || 'production'", workflow) - - -@unittest.skipUnless( - os.getenv(LONG_RUN_ENVIRONMENT, "").strip().lower() in {"1", "true", "yes"}, - f"Set {LONG_RUN_ENVIRONMENT}=1 to run bounded 0.01/0.2/1/5/10 s integration.", -) -class TestMql8ProgressiveLongRegressionTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH) - - def test_all_horizons_complete_or_stop_at_the_first_bounded_failure(self) -> None: - lane = os.getenv("TEST_MQL_8_REGRESSION_LANE", "production") - report = run_regression_suite(DEFAULT_MANIFEST_PATH, lane=lane) - outcomes = [case["outcome"] for case in report["cases"]] - - self.assertEqual(outcomes[0], "completed", report["cases"][0]) - if "deferred" in outcomes: - first_deferred = outcomes.index("deferred") - self.assertTrue( - all(outcome == "deferred" for outcome in outcomes[first_deferred:]) - ) - predecessor = report["cases"][first_deferred - 1] - self.fail( - "Progressive run stopped within its configured safety budget; " - f"optimize before resuming. Predecessor: {predecessor}" - ) - self.assertEqual( - outcomes, - ["completed"] * len(self.manifest["sequence"]), - report["cases"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_ame_contract.py b/tests/test_test_mql_ame_contract.py index a51ec19..6f081f8 100644 --- a/tests/test_test_mql_ame_contract.py +++ b/tests/test_test_mql_ame_contract.py @@ -20,8 +20,8 @@ from app.simulation.reporting.amesim_results import load_test_mql_amesim_results REPOSITORY_ROOT = Path(__file__).resolve().parents[1] AME_PATH = REPOSITORY_ROOT / "AmesimModels" / "test_mql.ame" -JSON_PATH = REPOSITORY_ROOT / "tests/data/test-mql-8.json" -XML_PATH = REPOSITORY_ROOT / "tests/data/test-mql-8.xml" +JSON_PATH = REPOSITORY_ROOT / "tests/baselines/simulation/test_mql_8/sources/test-mql-8.json" +XML_PATH = REPOSITORY_ROOT / "tests/baselines/simulation/test_mql_8/sources/test-mql-8.xml" MANIFEST_PATH = ( REPOSITORY_ROOT / "tests/baselines/simulation/test_mql_8/manifest.json" diff --git a/tests/test_test_mql_baseline.py b/tests/test_test_mql_baseline.py deleted file mode 100644 index e181205..0000000 --- a/tests/test_test_mql_baseline.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.baseline import run_test_mql_baseline_passthrough - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlBaselinePassthroughTests(unittest.TestCase): - def test_full_baseline_passthrough_satisfies_output_contract_with_zero_error(self) -> None: - run = run_test_mql_baseline_passthrough(TEST_MQL_AME) - - self.assertEqual(run.sample_count, 1002) - self.assertEqual(run.signal_count, 858) - self.assertEqual(run.output_schema.signal_count, 858) - self.assertEqual(run.observation_catalog.binding_count, 114) - self.assertEqual(len(run.comparison.metrics), 858) - self.assertEqual(run.comparison.max_abs_error, 0.0) - self.assertEqual(run.comparison.max_rel_error, 0.0) - self.assertEqual(run.output.data_paths, run.output_schema.data_paths()) - self.assertAlmostEqual(run.output.series("press@pn_c1_8")[-1], 4310310.457796034) - - def test_selected_baseline_passthrough_preserves_requested_order(self) -> None: - data_paths = ( - "vol1@pn_brp2_8", - "press@pn_c1_8", - "dm1@pneumatic_69", - ) - - run = run_test_mql_baseline_passthrough(TEST_MQL_AME, data_paths=data_paths) - - self.assertEqual(run.signal_count, 3) - self.assertEqual(run.output.data_paths, data_paths) - self.assertEqual(tuple(metric.data_path for metric in run.comparison.metrics), data_paths) - self.assertEqual(run.comparison.max_abs_error, 0.0) - - def test_baseline_passthrough_rejects_unobserved_data_path(self) -> None: - with self.assertRaises(KeyError): - run_test_mql_baseline_passthrough( - TEST_MQL_AME, - data_paths=("not_a_signal@not_an_owner",), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_branch_simulation.py b/tests/test_test_mql_branch_simulation.py deleted file mode 100644 index 64772d5..0000000 --- a/tests/test_test_mql_branch_simulation.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.solver import SolveIVPConfig -from app.simulation.examples.test_mql.system import TestMqlSystem - - -class TestMqlPneumaticBranchSimulationTests(unittest.TestCase): - def test_simulate_pneumatic_branch_returns_ode_solution(self) -> None: - system = TestMqlSystem() - - solution = system.simulate_pneumatic_branch( - name="sample_branch", - upstream_volume_alias="pn_general_chamber", - orifice_alias="pn_orifice_18", - downstream_volume_alias="pn_c1_8", - config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), - t_eval=[0.0, 5.0e-6, 1.0e-5], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(solution.t), 3) - self.assertEqual(len(solution.y), 4) - self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3]) - - def test_evaluate_pneumatic_branch_solution_reports_branch_series(self) -> None: - system = TestMqlSystem() - solution = system.simulate_pneumatic_branch( - name="sample_branch", - upstream_volume_alias="pn_general_chamber", - orifice_alias="pn_orifice_18", - downstream_volume_alias="pn_c1_8", - config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), - t_eval=[0.0, 5.0e-6, 1.0e-5], - ) - - series = system.evaluate_pneumatic_branch_solution( - solution, - name="sample_branch", - upstream_volume_alias="pn_general_chamber", - orifice_alias="pn_orifice_18", - downstream_volume_alias="pn_c1_8", - ) - - self.assertEqual(series["time"], [0.0, 5.0e-6, 1.0e-5]) - self.assertIn("sample_branch.flow", series) - self.assertIn("pn_general_chamber.p", series) - self.assertIn("pn_c1_8.p", series) - self.assertEqual(len(series["sample_branch.flow"]), 3) - self.assertGreater(series["sample_branch.flow"][0], 0.0) - self.assertGreater(series["pn_general_chamber.p"][0], series["pn_general_chamber.p"][-1]) - self.assertLess(series["pn_c1_8.p"][0], series["pn_c1_8.p"][-1]) - self.assertAlmostEqual( - series["pn_general_chamber.m_flow"][0], - -series["sample_branch.flow"][0], - ) - self.assertAlmostEqual( - series["pn_c1_8.m_flow"][0], - series["sample_branch.flow"][0], - ) - - def test_pneumatic_branch_spec_drives_closure_and_simulation(self) -> None: - system = TestMqlSystem() - spec = system.pneumatic_branch_spec( - name="sample_branch", - upstream_volume_alias="pn_general_chamber", - orifice_alias="pn_orifice_18", - downstream_volume_alias="pn_c1_8", - source="manual-test", - ) - - closure = system.pneumatic_branch_closure_from_spec(spec) - solution = system.simulate_pneumatic_branch_from_spec( - spec, - config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), - t_eval=[0.0, 1.0e-5], - ) - series = system.evaluate_pneumatic_branch_solution_from_spec(solution, spec) - - self.assertIs(closure.components.spec, spec) - self.assertEqual(spec.source, "manual-test") - self.assertEqual(len(solution.t), 2) - self.assertIn("sample_branch.flow", series) - self.assertGreater(series["sample_branch.flow"][0], 0.0) - self.assertGreater(series["pn_general_chamber.p"][0], series["pn_general_chamber.p"][-1]) - self.assertLess(series["pn_c1_8.p"][0], series["pn_c1_8.p"][-1]) - - def test_discover_pneumatic_branch_topology_reports_blocked_candidates(self) -> None: - system = TestMqlSystem() - - discovery = system.discover_pneumatic_branch_topology() - - self.assertEqual(discovery.branch_specs, ()) - self.assertEqual(len(discovery.blocked_candidates), 48) - candidates = { - candidate.connection_alias: candidate - for candidate in discovery.blocked_candidates - } - chamber_candidate = candidates["pneumatic_69"] - self.assertEqual(chamber_candidate.line_submodel, "PNL0001") - self.assertEqual(chamber_candidate.source_component, "pnnode4_16") - self.assertEqual(chamber_candidate.target_component, "pn_c1_8") - self.assertFalse(chamber_candidate.source_is_typed_pneumatic) - self.assertTrue(chamber_candidate.target_is_typed_pneumatic) - self.assertIn("node/line", chamber_candidate.reason) - - orifice_candidate = candidates["pneumatic_96"] - self.assertEqual(orifice_candidate.line_submodel, "PNL0001") - self.assertEqual(orifice_candidate.source_component, "pn_node3_8") - self.assertEqual(orifice_candidate.target_component, "pn_orifice_18") - self.assertFalse(orifice_candidate.source_is_typed_pneumatic) - self.assertTrue(orifice_candidate.target_is_typed_pneumatic) - self.assertIn("node/line", orifice_candidate.reason) - - def test_simulate_pneumatic_branch_rejects_invalid_component_aliases(self) -> None: - system = TestMqlSystem() - - with self.assertRaises(TypeError): - system.simulate_pneumatic_branch( - name="invalid_branch", - upstream_volume_alias="pn_brp2_8", - orifice_alias="pn_orifice_18", - downstream_volume_alias="pn_c1_8", - config=SolveIVPConfig(t_start=0.0, t_stop=0.0), - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_chamber_observations.py b/tests/test_test_mql_chamber_observations.py deleted file mode 100644 index 352b8c9..0000000 --- a/tests/test_test_mql_chamber_observations.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_chamber_observations import ( - build_test_mql_chamber_observation_catalog, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlChamberObservationCatalogTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.catalog = build_test_mql_chamber_observation_catalog(cls.amesim_results) - - def test_builds_expected_chamber_observation_bindings(self) -> None: - self.assertEqual(len(self.catalog.bindings), 12) - self.assertEqual(self.catalog.fixed_count, 4) - self.assertEqual(self.catalog.variable_count, 8) - - fixed = self.catalog.by_alias("pn_general_chamber") - variable = self.catalog.by_alias("pn_c1_8") - - self.assertEqual(fixed.submodel, "PNCH023") - self.assertFalse(fixed.is_variable) - self.assertEqual(fixed.pressure_path, "press@pn_general_chamber") - self.assertEqual(fixed.temperature_path, "temp@pn_general_chamber") - self.assertEqual(fixed.gas_mass_path, "mgas@pn_general_chamber") - self.assertEqual(fixed.pressure_duplicate_paths, ("press2@pn_general_chamber",)) - self.assertEqual(fixed.temperature_duplicate_paths, ("temp2@pn_general_chamber",)) - self.assertIsNone(fixed.volume_path) - self.assertEqual(variable.submodel, "PNCH012") - self.assertTrue(variable.is_variable) - self.assertEqual(variable.pressure_path, "press@pn_c1_8") - self.assertEqual(variable.temperature_path, "temp@pn_c1_8") - self.assertEqual(variable.gas_mass_path, "mgas1@pn_c1_8") - self.assertEqual( - variable.pressure_duplicate_paths, - ("press3@pn_c1_8", "press2@pn_c1_8", "press4@pn_c1_8"), - ) - self.assertEqual( - variable.temperature_duplicate_paths, - ("temp3@pn_c1_8", "temp2@pn_c1_8", "temp4@pn_c1_8"), - ) - self.assertEqual(variable.volume_path, "vol@pn_c1_8") - - def test_fixed_chamber_observation_matches_amesim_samples(self) -> None: - observation = self.catalog.by_alias("pn_general_chamber").observation_at( - self.amesim_results, - -1, - ) - - self.assertAlmostEqual(observation.time, self.amesim_results.times[-1]) - self.assertAlmostEqual(observation.pressure_pa, 4310273.332655074) - self.assertAlmostEqual(observation.temperature_k, 179.646447269399) - self.assertAlmostEqual(observation.gas_mass_g, 666.3851161956942) - self.assertIsNone(observation.volume_cm3) - - def test_variable_chamber_observation_matches_amesim_samples(self) -> None: - observation = self.catalog.by_alias("pn_c1_8").observation_at( - self.amesim_results, - -1, - ) - - self.assertAlmostEqual(observation.pressure_pa, 4310310.457796034) - self.assertAlmostEqual(observation.temperature_k, 293.1143259012449) - self.assertAlmostEqual(observation.gas_mass_g, 353.6887889918552) - self.assertAlmostEqual(observation.volume_cm3, 49242.54636512914) - - def test_initial_pressure_conventions_are_preserved(self) -> None: - fixed = self.catalog.by_alias("pn_general_chamber").observation_at( - self.amesim_results, - 0, - ) - variable = self.catalog.by_alias("pn_c1_8").observation_at( - self.amesim_results, - 0, - ) - - self.assertAlmostEqual(fixed.pressure_pa, 15198699.999999998) - self.assertAlmostEqual(variable.pressure_pa, -1299.99999999999) - self.assertAlmostEqual(fixed.temperature_k, 293.15) - self.assertAlmostEqual(variable.temperature_k, 293.15) - self.assertAlmostEqual(variable.volume_cm3, 15000.0) - - def test_duplicate_pressure_and_temperature_paths_match_primary_series(self) -> None: - for binding in self.catalog.bindings: - pressure = self.amesim_results.series(binding.pressure_path) - temperature = self.amesim_results.series(binding.temperature_path) - with self.subTest(alias=binding.alias): - for duplicate_path in binding.pressure_duplicate_paths: - duplicate = self.amesim_results.series(duplicate_path) - self.assertLess( - max(abs(a - b) for a, b in zip(pressure, duplicate)), - 1.0e-12, - ) - for duplicate_path in binding.temperature_duplicate_paths: - duplicate = self.amesim_results.series(duplicate_path) - self.assertLess( - max(abs(a - b) for a, b in zip(temperature, duplicate)), - 1.0e-12, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_chamber_segment.py b/tests/test_test_mql_chamber_segment.py deleted file mode 100644 index 90b0284..0000000 --- a/tests/test_test_mql_chamber_segment.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.solver import SolveIVPConfig -from app.simulation.examples.test_mql.system import TestMqlSystem - - -class TestMqlPneumaticChamberSegmentTests(unittest.TestCase): - def setUp(self) -> None: - self.system = TestMqlSystem() - self.discovery = self.system.discover_pneumatic_branch_topology() - self.spec = self.discovery.chamber_segment_specs[0] - - def test_discovers_four_fixed_chamber_segments_from_real_topology(self) -> None: - self.assertEqual(len(self.discovery.chamber_segment_specs), 4) - self.assertEqual(self.spec.volume_alias, "pn_general_chamber") - self.assertEqual(self.spec.inlet_node_alias, "pn_node3_8") - self.assertEqual(self.spec.inlet_line_alias, "pneumatic_96") - self.assertEqual(self.spec.inlet_orifice_alias, "pn_orifice_18") - self.assertEqual(self.spec.volume_inlet_port, "port_2") - self.assertEqual(self.spec.outlet_orifice_alias, "pn_orifice_19") - self.assertEqual(self.spec.outlet_line_alias, "pneumatic_97") - self.assertEqual(self.spec.outlet_node_alias, "pn_node3_9") - self.assertEqual(self.spec.source, "amesim-cir-topology") - - def test_segment_closure_writes_real_amesim_port_directions(self) -> None: - closure = self.system.pneumatic_chamber_segment_closure_from_spec( - self.spec, - inlet_pressure_pa=16.0e6, - outlet_pressure_pa=1.0e6, - ) - - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertGreater(snapshot.inlet_flow, 0.0) - self.assertGreater(snapshot.outlet_flow, 0.0) - self.assertAlmostEqual( - closure.components.volume.port_b.m_flow, - snapshot.inlet_flow, - ) - self.assertAlmostEqual( - closure.components.volume.port_a.m_flow, - -snapshot.outlet_flow, - ) - self.assertAlmostEqual( - closure.components.inlet_orifice.port_b.m_flow, - snapshot.inlet_flow, - ) - self.assertAlmostEqual( - closure.components.outlet_orifice.port_a.m_flow, - -snapshot.outlet_flow, - ) - self.assertEqual(len(rhs), 2) - self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow) - - def test_segment_closure_handles_reverse_boundary_pressures(self) -> None: - closure = self.system.pneumatic_chamber_segment_closure_from_spec( - self.spec, - inlet_pressure_pa=1.0e6, - outlet_pressure_pa=16.0e6, - ) - - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertLess(snapshot.inlet_flow, 0.0) - self.assertLess(snapshot.outlet_flow, 0.0) - self.assertAlmostEqual(rhs[0], snapshot.inlet_flow - snapshot.outlet_flow) - - def test_simulates_topology_derived_segment_with_prescribed_boundaries(self) -> None: - solution = self.system.simulate_pneumatic_chamber_segment_from_spec( - self.spec, - inlet_pressure_pa=16.0e6, - outlet_pressure_pa=14.0e6, - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0e-5, - max_step=1.0e-6, - ), - t_eval=[0.0, 5.0e-6, 1.0e-5], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(solution.t), 3) - self.assertEqual(len(solution.y), 2) - self.assertEqual([len(row) for row in solution.y], [3, 3]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_closure.py b/tests/test_test_mql_closure.py deleted file mode 100644 index 177d40c..0000000 --- a/tests/test_test_mql_closure.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, -) -from app.simulation.examples.test_mql.closure import ( - TestMqlPneumaticBranchComponents, - TestMqlPneumaticClosure, -) - - -class TestMqlPneumaticClosureTests(unittest.TestCase): - def _make_closure( - self, - *, - upstream_pressure: float = 15.3e6, - downstream_pressure: float = 1.0e5, - ) -> TestMqlPneumaticClosure: - upstream = AmesimPneumaticVolume.from_liters( - name="source_chamber", - volume_liters=57.0, - p0=upstream_pressure, - T0=293.15, - ) - downstream = AmesimPneumaticVolume.from_liters( - name="target_chamber", - volume_liters=15.0, - p0=downstream_pressure, - T0=293.15, - ) - orifice = AmesimPneumaticOrifice.from_mm2( - name="branch_orifice", - area_mm2=78.5, - flow_coefficient=0.9, - ) - - def initial_state_vector() -> list[float]: - return [*upstream.get_state_vector(), *downstream.get_state_vector()] - - def apply_state_vector(values: list[float]) -> None: - if len(values) != 4: - raise ValueError("two-volume closure state vector requires four values") - upstream.set_state_vector(values[:2]) - downstream.set_state_vector(values[2:]) - - return TestMqlPneumaticClosure( - components=TestMqlPneumaticBranchComponents( - name="source_to_target", - upstream_volume=upstream, - orifice=orifice, - downstream_volume=downstream, - ), - initial_state_vector=initial_state_vector, - apply_state_vector=apply_state_vector, - ) - - def test_snapshot_writes_modelica_style_port_flow_signs(self) -> None: - closure = self._make_closure() - - snapshot = closure.snapshot() - - self.assertGreater(snapshot.flow, 0.0) - self.assertAlmostEqual( - closure.components.upstream_volume.port_a.m_flow, - -snapshot.flow, - ) - self.assertAlmostEqual( - closure.components.orifice.port_a.m_flow, - snapshot.flow, - ) - self.assertAlmostEqual( - closure.components.orifice.port_b.m_flow, - -snapshot.flow, - ) - self.assertAlmostEqual( - closure.components.downstream_volume.port_a.m_flow, - snapshot.flow, - ) - self.assertAlmostEqual( - closure.components.orifice.port_a.p, - snapshot.upstream.p, - ) - self.assertAlmostEqual( - closure.components.orifice.port_b.p, - snapshot.downstream.p, - ) - - def test_rhs_uses_canonical_flow_for_volume_mass_balance(self) -> None: - closure = self._make_closure() - state = closure.initial_state_vector() - - snapshot = closure.snapshot(state) - rhs = closure.rhs(state) - - self.assertEqual(len(rhs), 4) - self.assertAlmostEqual(rhs[0], -snapshot.flow) - self.assertAlmostEqual(rhs[2], snapshot.flow) - self.assertAlmostEqual(rhs[0] + rhs[2], 0.0) - self.assertAlmostEqual(rhs[1], -snapshot.flow * snapshot.upstream.h) - self.assertAlmostEqual(rhs[3], snapshot.flow * snapshot.upstream.h) - - def test_reverse_pressure_reverses_canonical_flow_and_port_signs(self) -> None: - closure = self._make_closure( - upstream_pressure=1.0e5, - downstream_pressure=15.3e6, - ) - - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertLess(snapshot.flow, 0.0) - self.assertGreater(closure.components.upstream_volume.port_a.m_flow, 0.0) - self.assertLess(closure.components.downstream_volume.port_a.m_flow, 0.0) - self.assertAlmostEqual(rhs[0], -snapshot.flow) - self.assertAlmostEqual(rhs[2], snapshot.flow) - self.assertAlmostEqual(rhs[0] + rhs[2], 0.0) - self.assertAlmostEqual(rhs[1], -snapshot.flow * snapshot.downstream.h) - self.assertAlmostEqual(rhs[3], snapshot.flow * snapshot.downstream.h) - - def test_snapshot_can_apply_explicit_state_vector(self) -> None: - closure = self._make_closure() - state = closure.initial_state_vector() - state[0] *= 0.99 - - snapshot = closure.snapshot(state) - - self.assertAlmostEqual( - closure.components.upstream_volume.state.m, - state[0], - ) - self.assertGreater(snapshot.upstream.p, snapshot.downstream.p) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_comparison.py b/tests/test_test_mql_comparison.py deleted file mode 100644 index 726926a..0000000 --- a/tests/test_test_mql_comparison.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -import csv -import tempfile -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import ( - AmesimResults, - load_test_mql_amesim_results, -) -from app.simulation.reporting.test_mql_comparison import ( - DEFAULT_TEST_MQL_ALIGNMENT_PATHS, - TestMqlComparisonError, - compare_test_mql_series, - interpolate_series_value, - write_test_mql_amesim_baseline_csv, - write_test_mql_comparison_csv, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlComparisonTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_exact_amesim_series_compare_with_zero_error(self) -> None: - data_paths = ("temp3@pn_c1_8", "press3@pn_c1_8") - python_series = { - data_path: self.amesim_results.series(data_path) - for data_path in data_paths - } - - comparison = compare_test_mql_series( - python_times=self.amesim_results.times, - python_series_by_data_path=python_series, - amesim_results=self.amesim_results, - data_paths=data_paths, - ) - - self.assertEqual(len(comparison.metrics), 2) - self.assertEqual(comparison.max_abs_error, 0.0) - self.assertEqual(comparison.metric("temp3@pn_c1_8").sample_count, 1002) - - def test_coarse_python_times_are_interpolated_against_amesim(self) -> None: - data_path = "temp3@pn_c1_8" - python_times = self.amesim_results.times[::100] - python_series = { - data_path: tuple( - interpolate_series_value( - self.amesim_results.times, - self.amesim_results.series(data_path), - time_value, - ) - for time_value in python_times - ) - } - - comparison = compare_test_mql_series( - python_times=python_times, - python_series_by_data_path=python_series, - amesim_results=self.amesim_results, - data_paths=(data_path,), - ) - - self.assertEqual(comparison.metric(data_path).sample_count, len(python_times)) - self.assertEqual(comparison.max_abs_error, 0.0) - - def test_offset_series_reports_abs_and_relative_error(self) -> None: - data_path = "temp3@pn_c1_8" - python_series = { - data_path: tuple(value + 1.0 for value in self.amesim_results.series(data_path)) - } - - comparison = compare_test_mql_series( - python_times=self.amesim_results.times, - python_series_by_data_path=python_series, - amesim_results=self.amesim_results, - data_paths=(data_path,), - ) - metric = comparison.metric(data_path) - - self.assertAlmostEqual(metric.max_abs_error, 1.0) - self.assertAlmostEqual(metric.mean_abs_error, 1.0) - self.assertGreater(metric.max_rel_error, 0.0) - - def test_zero_amesim_baseline_does_not_invent_a_relative_denominator(self) -> None: - amesim_results = AmesimResults( - times=(0.0, 1.0), - variables=(), - saved_variable_indices=(), - series_by_data_path={"zero@baseline": (0.0, -0.0)}, - final_values_by_data_path={"zero@baseline": -0.0}, - ) - - comparison = compare_test_mql_series( - python_times=(0.0, 1.0), - python_series_by_data_path={"zero@baseline": (0.0, 1.0e-6)}, - amesim_results=amesim_results, - data_paths=("zero@baseline",), - ) - metric = comparison.metric("zero@baseline") - - self.assertEqual(metric.max_abs_error, 1.0e-6) - self.assertEqual(metric.undefined_rel_error_count, 2) - self.assertEqual(metric.near_zero_baseline_count, 2) - self.assertEqual(comparison.undefined_rel_error_count, 2) - self.assertEqual(metric.max_rel_error, 0.0) - - with tempfile.TemporaryDirectory() as temp_dir: - comparison_path, _summary_path, _comparison = ( - write_test_mql_comparison_csv( - output_dir=Path(temp_dir), - python_times=(0.0, 1.0), - python_series_by_data_path={ - "zero@baseline": (0.0, 1.0e-6) - }, - amesim_results=amesim_results, - data_paths=("zero@baseline",), - ) - ) - with comparison_path.open(newline="", encoding="utf-8") as handle: - rows = list(csv.reader(handle)) - self.assertEqual(rows[1][-1], "") - self.assertEqual(rows[2][-1], "") - - def test_missing_data_path_is_rejected(self) -> None: - with self.assertRaises(TestMqlComparisonError): - compare_test_mql_series( - python_times=self.amesim_results.times, - python_series_by_data_path={"missing@component": (1.0,) * 1002}, - amesim_results=self.amesim_results, - ) - - def test_writes_baseline_and_comparison_csv_files(self) -> None: - data_paths = DEFAULT_TEST_MQL_ALIGNMENT_PATHS[:2] - python_series = { - data_path: self.amesim_results.series(data_path) - for data_path in data_paths - } - - with tempfile.TemporaryDirectory() as temp_dir: - output_dir = Path(temp_dir) - baseline_path = write_test_mql_amesim_baseline_csv( - output_dir, - self.amesim_results, - data_paths=data_paths, - ) - comparison_path, summary_path, comparison = write_test_mql_comparison_csv( - output_dir=output_dir, - python_times=self.amesim_results.times, - python_series_by_data_path=python_series, - amesim_results=self.amesim_results, - data_paths=data_paths, - ) - - self.assertTrue(baseline_path.exists()) - self.assertTrue(comparison_path.exists()) - self.assertTrue(summary_path.exists()) - self.assertIn("time_s,temp3@pn_c1_8,press3@pn_c1_8", baseline_path.read_text(encoding="utf-8")) - self.assertEqual(comparison.max_abs_error, 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_computed.py b/tests/test_test_mql_computed.py deleted file mode 100644 index 7bc541c..0000000 --- a/tests/test_test_mql_computed.py +++ /dev/null @@ -1,272 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.computed import ( - run_test_mql_computed_geometry, - run_test_mql_computed_line_relations, - run_test_mql_computed_mechanical_relations, - run_test_mql_computed_piston_geometry, - run_test_mql_computed_pneumatic_relations, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlComputedPistonGeometryTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.computed_run = run_test_mql_computed_piston_geometry(TEST_MQL_AME) - - def test_computed_piston_geometry_outputs_schema_valid_series(self) -> None: - self.assertEqual(self.computed_run.sample_count, 1002) - self.assertEqual(len(self.computed_run.mechanical_assembly.pistons), 8) - self.assertEqual(self.computed_run.signal_count, 24) - self.assertEqual(len(self.computed_run.comparison.metrics), 24) - self.assertEqual(self.computed_run.output_schema.signal_count, 858) - - def test_computed_piston_geometry_output_paths_are_expected(self) -> None: - paths = self.computed_run.output.data_paths - - self.assertIn("length@pn_brp2_8", paths) - self.assertIn("vol1@pn_brp2_8", paths) - self.assertIn("vvol1@pn_brp2_8", paths) - self.assertIn("length@pn_brp2_15", paths) - self.assertEqual(len([path for path in paths if path.startswith("length@")]), 8) - self.assertEqual(len([path for path in paths if path.startswith("vol1@")]), 8) - self.assertEqual(len([path for path in paths if path.startswith("vvol1@")]), 8) - - def test_computed_piston_geometry_matches_amesim_with_tight_error_bounds(self) -> None: - for data_path in self.computed_run.output.data_paths: - metric = self.computed_run.comparison.metric(data_path) - with self.subTest(data_path=data_path): - if data_path.startswith("length@"): - self.assertLessEqual(metric.max_abs_error, 3.0e-13) - elif data_path.startswith("vol1@"): - self.assertLessEqual(metric.max_abs_error, 8.0e-12) - elif data_path.startswith("vvol1@"): - self.assertLessEqual(metric.max_abs_error, 3.0e-11) - else: - self.fail(f"Unexpected computed piston geometry path: {data_path}") - - def test_computed_piston_geometry_final_samples_match_expected_values(self) -> None: - self.assertAlmostEqual( - self.computed_run.output.series("length@pn_brp2_8")[-1], - 1090.0013536465842, - ) - self.assertAlmostEqual( - self.computed_run.output.series("vol1@pn_brp2_8")[-1], - 34242.54636512914, - delta=1.0e-9, - ) - self.assertAlmostEqual( - self.computed_run.output.series("vvol1@pn_brp2_8")[-1], - 8.360141002531034e-07, - delta=1.0e-11, - ) - - -class TestMqlComputedGeometryTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.computed_run = run_test_mql_computed_geometry(TEST_MQL_AME) - - def test_computed_geometry_outputs_schema_valid_series(self) -> None: - self.assertEqual(self.computed_run.sample_count, 1002) - self.assertEqual(len(self.computed_run.mechanical_assembly.pistons), 8) - self.assertEqual(len(self.computed_run.pneumatic_assembly.variable_chambers), 8) - self.assertEqual(self.computed_run.signal_count, 32) - self.assertEqual(len(self.computed_run.comparison.metrics), 32) - self.assertEqual(self.computed_run.output_schema.signal_count, 858) - - def test_computed_geometry_includes_variable_chamber_volume_paths(self) -> None: - paths = self.computed_run.output.data_paths - - self.assertIn("vol@pn_c1_8", paths) - self.assertIn("vol@pn_c1_15", paths) - self.assertEqual(len([path for path in paths if path.startswith("vol@pn_c1")]), 8) - self.assertEqual(len([path for path in paths if path.startswith("length@")]), 8) - self.assertEqual(len([path for path in paths if path.startswith("vol1@")]), 8) - self.assertEqual(len([path for path in paths if path.startswith("vvol1@")]), 8) - - def test_computed_variable_chamber_volumes_match_amesim_with_tight_error_bounds(self) -> None: - for data_path in self.computed_run.output.data_paths: - metric = self.computed_run.comparison.metric(data_path) - with self.subTest(data_path=data_path): - if data_path.startswith("vol@pn_c1"): - self.assertLessEqual(metric.max_abs_error, 8.0e-12) - - def test_computed_variable_chamber_volume_final_samples_match_expected_values(self) -> None: - self.assertAlmostEqual( - self.computed_run.output.series("vol@pn_c1_8")[-1], - 49242.54636512914, - delta=1.0e-9, - ) - self.assertAlmostEqual( - self.computed_run.output.series("vol@pn_c1_15")[-1], - 49242.546365149814, - delta=1.0e-9, - ) - - -class TestMqlComputedLineRelationsTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.computed_run = run_test_mql_computed_line_relations(TEST_MQL_AME) - - def test_computed_line_relations_outputs_schema_valid_series(self) -> None: - self.assertEqual(self.computed_run.sample_count, 1002) - self.assertEqual(len(self.computed_run.observation_catalog.lines.by_submodel("PNL00R")), 4) - self.assertEqual(self.computed_run.signal_count, 8) - self.assertEqual(len(self.computed_run.comparison.metrics), 8) - self.assertEqual(self.computed_run.output_schema.signal_count, 858) - - def test_computed_line_relations_include_expected_paths(self) -> None: - paths = self.computed_run.output.data_paths - - self.assertIn("dm2@pneumatic_100", paths) - self.assertIn("dh2@pneumatic_100", paths) - self.assertEqual(len([path for path in paths if path.startswith("dm2@")]), 4) - self.assertEqual(len([path for path in paths if path.startswith("dh2@")]), 4) - - def test_computed_line_relations_match_amesim_exactly(self) -> None: - for data_path in self.computed_run.output.data_paths: - metric = self.computed_run.comparison.metric(data_path) - with self.subTest(data_path=data_path): - self.assertEqual(metric.max_abs_error, 0.0) - - def test_computed_line_relations_final_samples_match_expected_values(self) -> None: - self.assertAlmostEqual( - self.computed_run.output.series("dm2@pneumatic_100")[-1], - 1.4837698580885316e-30, - ) - self.assertAlmostEqual( - self.computed_run.output.series("dh2@pneumatic_100")[-1], - -1.2638893078048237e-28, - ) - - -class TestMqlComputedPneumaticRelationsTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.computed_run = run_test_mql_computed_pneumatic_relations(TEST_MQL_AME) - - def test_computed_pneumatic_relations_outputs_schema_valid_series(self) -> None: - self.assertEqual(self.computed_run.sample_count, 1002) - self.assertEqual(self.computed_run.observation_catalog.chambers.fixed_count, 4) - self.assertEqual(self.computed_run.observation_catalog.chambers.variable_count, 8) - self.assertEqual(self.computed_run.observation_catalog.orifices.fixed_count, 8) - self.assertEqual(self.computed_run.observation_catalog.orifices.variable_count, 8) - self.assertEqual(self.computed_run.signal_count, 88) - self.assertEqual(len(self.computed_run.comparison.metrics), 88) - self.assertEqual(self.computed_run.output_schema.signal_count, 858) - - def test_computed_pneumatic_relations_include_expected_paths(self) -> None: - paths = self.computed_run.output.data_paths - - self.assertIn("press2@pn_c1_8", paths) - self.assertIn("temp2@pn_c1_8", paths) - self.assertIn("dm2@pn_orifice_18", paths) - self.assertIn("dh2@pn_orifice_18", paths) - self.assertEqual(len([path for path in paths if path.startswith("press")]), 28) - self.assertEqual(len([path for path in paths if path.startswith("temp")]), 28) - self.assertEqual(len([path for path in paths if path.startswith("dm")]), 16) - self.assertEqual(len([path for path in paths if path.startswith("dh")]), 16) - - def test_computed_pneumatic_relations_match_amesim_exactly(self) -> None: - for data_path in self.computed_run.output.data_paths: - metric = self.computed_run.comparison.metric(data_path) - with self.subTest(data_path=data_path): - self.assertEqual(metric.max_abs_error, 0.0) - - def test_computed_pneumatic_relations_final_samples_match_expected_values(self) -> None: - self.assertAlmostEqual( - self.computed_run.output.series("press2@pn_c1_8")[-1], - 4310310.457796034, - ) - self.assertAlmostEqual( - self.computed_run.output.series("temp2@pn_c1_8")[-1], - 293.1143259012449, - ) - self.assertAlmostEqual( - self.computed_run.output.series("dm2@pn_orifice_18")[-1], - -0.009097746783809915, - ) - self.assertAlmostEqual( - self.computed_run.output.series("dh2@pn_orifice_18")[-1], - 1.1281175640493923, - ) - - -class TestMqlComputedMechanicalRelationsTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.computed_run = run_test_mql_computed_mechanical_relations(TEST_MQL_AME) - - def test_computed_mechanical_relations_outputs_schema_valid_series(self) -> None: - self.assertEqual(self.computed_run.sample_count, 1002) - self.assertEqual(len(self.computed_run.mechanical_assembly.masses), 10) - self.assertEqual(self.computed_run.signal_count, 76) - self.assertEqual(len(self.computed_run.comparison.metrics), 76) - self.assertEqual(self.computed_run.output_schema.signal_count, 858) - - def test_computed_mechanical_relations_include_expected_paths(self) -> None: - paths = self.computed_run.output.data_paths - - self.assertIn("x1dup@mass_friction_endstops_10", paths) - self.assertIn("v1dup@mass_friction_endstops_10", paths) - self.assertIn("acc1dup@mass_friction_endstops_10", paths) - self.assertIn("Fmin@mass_friction_endstops_10", paths) - self.assertIn("Fvisc@mass_friction_endstops_10", paths) - self.assertIn("Ffric@mass_friction_endstops_10", paths) - self.assertIn("fzero@zeroforcesource_17", paths) - self.assertEqual(len([path for path in paths if path.startswith("x1dup@")]), 10) - self.assertEqual(len([path for path in paths if path.startswith("v1dup@")]), 10) - self.assertEqual(len([path for path in paths if path.startswith("acc1dup@")]), 10) - self.assertEqual(len([path for path in paths if path.startswith("Fmin@")]), 10) - self.assertEqual(len([path for path in paths if path.startswith("Fvisc@")]), 10) - self.assertEqual(len([path for path in paths if path.startswith("Ffric@")]), 10) - self.assertEqual(len([path for path in paths if path.startswith("fzero@")]), 16) - - def test_computed_mechanical_relations_match_amesim_exactly(self) -> None: - for data_path in self.computed_run.output.data_paths: - metric = self.computed_run.comparison.metric(data_path) - with self.subTest(data_path=data_path): - self.assertEqual(metric.max_abs_error, 0.0) - - def test_computed_mechanical_relations_final_samples_match_expected_values(self) -> None: - self.assertAlmostEqual( - self.computed_run.output.series("x1dup@mass_friction_endstops_10")[-1], - -0.3700013536465843, - ) - self.assertAlmostEqual( - self.computed_run.output.series("v1dup@mass_friction_endstops_10")[-1], - -4.435303434246023e-10, - ) - self.assertAlmostEqual( - self.computed_run.output.series("acc1dup@mass_friction_endstops_10")[-1], - 3.5662378650158644e-07, - ) - self.assertEqual( - self.computed_run.output.series("Fmin@mass_friction_endstops_10")[-1], - 0.0, - ) - self.assertEqual( - self.computed_run.output.series("Fvisc@mass_friction_endstops_10")[-1], - 0.0, - ) - self.assertEqual( - self.computed_run.output.series("Ffric@mass_friction_endstops_10")[-1], - 0.0, - ) - self.assertEqual( - self.computed_run.output.series("fzero@zeroforcesource_17")[-1], - 0.0, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_config.py b/tests/test_test_mql_config.py deleted file mode 100644 index bf619d7..0000000 --- a/tests/test_test_mql_config.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.config import TestMqlConfig, resolve_numeric_expression - - -class TestMqlConfigTest(unittest.TestCase): - def test_resolves_global_parameters(self) -> None: - config = TestMqlConfig.from_amesim_specs() - - self.assertEqual(config.global_parameters["D1"], 20.0) - self.assertEqual(config.global_parameters["D2"], 20.0) - self.assertEqual(config.global_parameters["D3"], 14.0) - self.assertEqual(config.global_parameters["P0"], 153.0) - self.assertEqual(config.global_parameters["Pdq"], 1.0) - self.assertEqual(config.global_parameters["V"], 15.0) - self.assertEqual(config.global_parameters["cf"], 0.45) - self.assertEqual(config.fluid.name, "helium") - self.assertAlmostEqual(config.fluid.specific_gas_constant, 2077.3, delta=0.5) - - def test_resolves_amesim_parameter_expressions(self) -> None: - variables = {"D2": 20.0, "P0": 153.0, "cf": 0.45} - - self.assertAlmostEqual(resolve_numeric_expression("3.14*10^2/4", variables), 78.5) - self.assertAlmostEqual(resolve_numeric_expression("0.045/D2", variables), 0.00225) - self.assertAlmostEqual(resolve_numeric_expression("cf", variables), 0.45) - self.assertAlmostEqual( - resolve_numeric_expression("(P0-1.01300000000000e+00)/1.00000000000000e-05", variables), - 15198700.0, - ) - self.assertIsNone(resolve_numeric_expression("predefined nitrogen (N2)", variables)) - - def test_groups_components_by_submodel(self) -> None: - config = TestMqlConfig.from_amesim_specs() - - self.assertEqual(len(config.components_by_submodel("PNRP17")), 8) - self.assertEqual(len(config.components_by_submodel("PNCH012")), 8) - self.assertEqual(len(config.components_by_submodel("PNOR001")), 8) - self.assertEqual(len(config.components_by_submodel("PNVO001")), 8) - self.assertEqual(len(config.components_by_submodel("PNGD00")), 1) - - def test_resolves_representative_component_parameters(self) -> None: - config = TestMqlConfig.from_amesim_specs() - - piston = config.component("pn_brp2_8") - self.assertEqual(piston.submodel, "PNRP17") - self.assertEqual(piston.parameter_value("dp"), 200.0) - self.assertEqual(piston.parameter_value("dr"), 1.0) - - fixed_orifice = config.component("pn_orifice_18") - self.assertEqual(fixed_orifice.submodel, "PNOR001") - self.assertAlmostEqual(fixed_orifice.parameter_value("area"), 78.5) - - variable_orifice = config.component("pn_morifice_11") - self.assertEqual(variable_orifice.submodel, "PNVO001") - self.assertAlmostEqual(variable_orifice.parameter_value("cq"), 0.45) - self.assertAlmostEqual(variable_orifice.parameter_value("area0"), 78.5) - - chamber = config.component("pn_c1_8") - self.assertEqual(chamber.submodel, "PNCH012") - self.assertEqual(chamber.parameter_value("cvol0"), 15.0) - - gas = config.component("pn_gas_data") - self.assertEqual(gas.submodel, "PNGD00") - self.assertAlmostEqual(gas.parameter_value("rGas"), 287.2) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_example_runner.py b/tests/test_test_mql_example_runner.py deleted file mode 100644 index 72ef66b..0000000 --- a/tests/test_test_mql_example_runner.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from app.main import ReactFlowProjectPayload, run_reactflow_test_mql -from app.simulation.examples.test_mql.run import prepare_test_mql_run, run_test_mql -from app.simulation.examples.test_mql.solver import SolveIVPConfig -from app.simulation.examples.test_mql.system import TestMqlRunConfig -from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR - - -class TestMqlExampleRunnerTests(unittest.TestCase): - def test_test_mql_keeps_calibrated_default_tolerance(self) -> None: - self.assertEqual(SolveIVPConfig().atol, 1.0e-10) - - def test_default_run_paths_use_app_simulation_runs_dir(self) -> None: - prepared = prepare_test_mql_run() - - self.assertEqual(prepared.repo_root, PROJECT_ROOT) - self.assertEqual(prepared.output_dir.parent, SIMULATION_RUNS_DIR) - - def test_zero_duration_run_writes_summary(self) -> None: - with tempfile.TemporaryDirectory() as directory: - output_dir = Path(directory) / "test_mql" - result = run_test_mql( - run_config=TestMqlRunConfig(t_start=0.0, t_stop=0.0), - output_dir=output_dir, - ) - - self.assertEqual(result.result.t, [0.0]) - self.assertEqual(result.summary_path, output_dir / "test_mql_model_summary.txt") - self.assertIn( - "Model: test_mql", - result.summary_path.read_text(encoding="utf-8"), - ) - - def test_reactflow_helper_runs_fixed_topology_test_mql(self) -> None: - payload = ReactFlowProjectPayload( - projectSchemaVersion=1, - name="test-mql-fixed", - simulation={ - "t_start": 0.0, - "t_stop": 0.2, - "step": 0.1, - "max_step": 0.001, - "method": "BDF", - }, - ) - - result = run_reactflow_test_mql(payload) - - self.assertTrue(result["success"]) - self.assertEqual(result["model"]["name"], "test_mql") - self.assertEqual(result["model"]["componentCount"], 117) - self.assertEqual(result["series"]["time"], [0.0, 0.1, 0.2]) - self.assertIn("summary", result["artifacts"]) - - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_line_observations.py b/tests/test_test_mql_line_observations.py deleted file mode 100644 index 996193f..0000000 --- a/tests/test_test_mql_line_observations.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_line_observations import ( - G_PER_S_TO_KG_PER_S, - build_test_mql_line_observation_catalog, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlLineObservationCatalogTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.catalog = build_test_mql_line_observation_catalog(cls.amesim_results) - - def test_builds_expected_line_observation_counts(self) -> None: - self.assertEqual(self.catalog.line_count, 40) - self.assertEqual(len(self.catalog.by_submodel("PNL0001")), 20) - self.assertEqual(len(self.catalog.by_submodel("PNL0002")), 8) - self.assertEqual(len(self.catalog.by_submodel("PNL0003")), 8) - self.assertEqual(len(self.catalog.by_submodel("PNL00R")), 4) - - def test_pnl0001_binding_exposes_single_flow_and_compliance_observables(self) -> None: - binding = self.catalog.by_alias("pneumatic_69") - - self.assertEqual(binding.submodel, "PNL0001") - self.assertEqual(binding.pattern, "C-R") - self.assertEqual(binding.mass_flow_paths, ("dm1@pneumatic_69",)) - self.assertEqual(binding.enthalpy_flow_paths, ("dh1@pneumatic_69",)) - self.assertEqual(binding.pressure_paths, ("p2@pneumatic_69",)) - self.assertEqual(binding.temperature_paths, ("t2@pneumatic_69",)) - self.assertEqual(binding.gas_mass_path, "mgas@pneumatic_69") - self.assertEqual(binding.reynolds_path, "re@pneumatic_69") - self.assertEqual(binding.mass_flow_parameter_path, "cm@pneumatic_69") - self.assertEqual(binding.gas_velocity_path, "v@pneumatic_69") - self.assertEqual(binding.friction_factor_path, "ff@pneumatic_69") - - def test_pnl0002_and_pnl0003_keep_distinct_flow_topologies(self) -> None: - rcr = self.catalog.by_alias("pneumatic_80") - crc = self.catalog.by_alias("pneumatic_75") - - self.assertEqual(rcr.pattern, "R-C-R") - self.assertEqual(rcr.mass_flow_paths, ("dm1@pneumatic_80", "dm2@pneumatic_80")) - self.assertEqual(rcr.enthalpy_flow_paths, ("dh1@pneumatic_80", "dh2@pneumatic_80")) - self.assertEqual(rcr.pressure_paths, ("pctr@pneumatic_80",)) - self.assertEqual(rcr.temperature_paths, ("tctr@pneumatic_80",)) - self.assertEqual(crc.pattern, "C-R-C") - self.assertEqual(crc.mass_flow_paths, ("dmctr@pneumatic_75",)) - self.assertEqual(crc.enthalpy_flow_paths, ("dhctr@pneumatic_75",)) - self.assertEqual(crc.pressure_paths, ("p1@pneumatic_75", "p2@pneumatic_75")) - self.assertEqual(crc.temperature_paths, ("t1@pneumatic_75", "t2@pneumatic_75")) - - def test_mass_flow_series_are_converted_to_si_units(self) -> None: - binding = self.catalog.by_alias("pneumatic_69") - mass_flow = binding.mass_flow_kg_s_series(self.amesim_results) - - self.assertAlmostEqual( - mass_flow[-1], - self.amesim_results.series("dm1@pneumatic_69")[-1] * G_PER_S_TO_KG_PER_S, - ) - self.assertAlmostEqual(mass_flow[-1], -9.527778353091475e-6) - self.assertAlmostEqual(max(mass_flow), 0.4490761730524304) - - def test_observation_exposes_line_state_and_diagnostics(self) -> None: - observation = self.catalog.by_alias("pneumatic_75").observation_at( - self.amesim_results, - -1, - ) - - self.assertAlmostEqual(observation.time, self.amesim_results.times[-1]) - self.assertAlmostEqual( - observation.mass_flows_kg_s["dmctr@pneumatic_75"], - 9.486502325625855e-6, - ) - self.assertAlmostEqual( - observation.enthalpy_flows_w["dhctr@pneumatic_75"], - -0.7322143070637952, - ) - self.assertAlmostEqual(observation.pressures_pa["p1@pneumatic_75"], 4310289.672688478) - self.assertAlmostEqual(observation.pressures_pa["p2@pneumatic_75"], 4310295.447776705) - self.assertAlmostEqual(observation.temperatures_k["t1@pneumatic_75"], 281.37837485614546) - self.assertAlmostEqual(observation.temperatures_k["t2@pneumatic_75"], 282.9136549991407) - self.assertAlmostEqual(observation.gas_mass_g, 0.7031236652270819) - self.assertAlmostEqual(observation.reynolds_number, 31.539799199000434) - self.assertAlmostEqual(observation.mass_flow_parameter, 6.351754064211156e-07) - self.assertAlmostEqual(observation.gas_velocity_m_s, 0.0040585650473562935) - self.assertAlmostEqual(observation.friction_factor, 2.0291822277051246) - - def test_resistance_only_lines_preserve_reversed_duplicate_signs(self) -> None: - for binding in self.catalog.by_submodel("PNL00R"): - dm1 = self.amesim_results.series(f"dm1@{binding.alias}") - dm2 = self.amesim_results.series(f"dm2@{binding.alias}") - dh1 = self.amesim_results.series(f"dh1@{binding.alias}") - dh2 = self.amesim_results.series(f"dh2@{binding.alias}") - with self.subTest(alias=binding.alias): - self.assertIsNone(binding.gas_mass_path) - self.assertEqual(binding.pressure_paths, ()) - self.assertEqual(binding.temperature_paths, ()) - self.assertLess(max(abs(a + b) for a, b in zip(dm1, dm2)), 1.0e-36) - self.assertLess(max(abs(a + b) for a, b in zip(dh1, dh2)), 1.0e-34) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_lines.py b/tests/test_test_mql_lines.py deleted file mode 100644 index 8c4ce89..0000000 --- a/tests/test_test_mql_lines.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog -from app.simulation.examples.test_mql.lines import build_test_mql_line_assembly - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlLineAssemblyTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.variable_catalog = build_test_mql_variable_catalog(cls.amesim_results) - cls.assembly = build_test_mql_line_assembly( - cls.amesim_results, - cls.variable_catalog, - ) - - def test_assembles_expected_line_counts(self) -> None: - self.assertEqual(self.assembly.line_count, 40) - self.assertEqual( - self.assembly.counts_by_submodel(), - {"PNL0001": 20, "PNL0003": 8, "PNL0002": 8, "PNL00R": 4}, - ) - self.assertIn("pneumatic_69", self.assembly.aliases()) - self.assertIn("pneumatic_100", self.assembly.aliases()) - - def test_preserves_connection_topology(self) -> None: - line = self.assembly.by_alias("pneumatic_69") - - self.assertEqual(line.index, 37) - self.assertEqual(line.submodel, "PNL0001") - self.assertEqual(line.pattern, "C-R") - self.assertEqual(line.source_component, "pnnode4_16") - self.assertEqual(line.source_port, "port_2") - self.assertEqual(line.target_component, "pn_c1_8") - self.assertEqual(line.target_port, "port_1") - self.assertTrue(line.has_compliance) - self.assertTrue(line.has_resistance) - - def test_line_data_paths_are_attached_from_amesim_results(self) -> None: - line = self.assembly.by_alias("pneumatic_69") - - self.assertIn("dh1@pneumatic_69", line.data_paths) - self.assertIn("dm1@pneumatic_69", line.data_paths) - self.assertIn("dh1", line.signal_names) - self.assertIn("dm1", line.signal_names) - - def test_resistance_only_line_keeps_r_pattern(self) -> None: - line = self.assembly.by_alias("pneumatic_100") - - self.assertEqual(line.submodel, "PNL00R") - self.assertEqual(line.pattern, "R") - self.assertFalse(line.has_compliance) - self.assertTrue(line.has_resistance) - self.assertIn("dm1@pneumatic_100", line.data_paths) - - def test_rcr_and_crc_patterns_are_distinct(self) -> None: - self.assertEqual(self.assembly.by_alias("pneumatic_80").pattern, "R-C-R") - self.assertEqual(self.assembly.by_alias("pneumatic_75").pattern, "C-R-C") - self.assertEqual(len(self.assembly.by_submodel("PNL0002")), 8) - self.assertEqual(len(self.assembly.by_submodel("PNL0003")), 8) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_mechanical.py b/tests/test_test_mql_mechanical.py deleted file mode 100644 index 9fe9d24..0000000 --- a/tests/test_test_mql_mechanical.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.examples.test_mql.solver import SolveIVPConfig -from app.simulation.reporting.test_mql_variables import build_test_mql_variable_catalog -from app.simulation.examples.test_mql.system import TestMqlSystem -from app.simulation.examples.test_mql.mechanical import ( - TestMqlMechanicalMassClosure, - build_test_mql_mechanical_assembly, - circular_area, - mm_to_m, - n_per_mm_per_s_to_n_per_m_per_s, - n_per_mm_to_n_per_m, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlMechanicalAssemblyTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.variable_catalog = build_test_mql_variable_catalog(cls.amesim_results) - cls.assembly = build_test_mql_mechanical_assembly( - amesim_results=cls.amesim_results, - variable_catalog=cls.variable_catalog, - ) - - def test_assembles_expected_mechanical_component_counts(self) -> None: - self.assertEqual(len(self.assembly.pistons), 8) - self.assertEqual(len(self.assembly.masses), 10) - self.assertEqual(len(self.assembly.elastic_endstops), 8) - self.assertEqual(len(self.assembly.mechanical_nodes), 2) - self.assertEqual(len(self.assembly.piecewise_signals), 2) - self.assertEqual(len(self.assembly.force_connectors), 2) - self.assertEqual(len(self.assembly.zero_force_sources), 16) - self.assertEqual(self.assembly.component_count, 48) - - def test_piecewise_force_sources_match_amesim_samples(self) -> None: - front_signal = self.assembly.piecewise_signals["piecewiselinear_1"] - rear_signal = self.assembly.piecewise_signals["piecewiselinear"] - - self.assertAlmostEqual(front_signal.output_at(0.0), 1.0e12) - self.assertAlmostEqual(front_signal.output_at(0.8000000000000009), 0.0) - self.assertAlmostEqual(rear_signal.output_at(0.0), 1.0e17) - self.assertAlmostEqual(rear_signal.output_at(0.8000000000000009), 49000.0) - self.assertEqual( - self.assembly.force_connectors["forcecon_2"].target_mass_alias, - "mass_friction_endstops_18", - ) - self.assertEqual( - self.assembly.force_connectors["forcecon_1"].target_mass_alias, - "mass_friction_endstops_19", - ) - self.assertAlmostEqual( - self.assembly.force_connectors["forcecon_2"].force_at( - 0.0, - self.assembly.piecewise_signals, - ), - self.amesim_results.series("force@forcecon_2")[0], - ) - self.assertAlmostEqual( - self.assembly.force_connectors["forcecon_1"].force_at( - 0.8000000000000009, - self.assembly.piecewise_signals, - ), - self.amesim_results.series("force@forcecon_1")[80], - ) - - def test_mechanical_mass_closure_exposes_all_mecmas21_states(self) -> None: - closure = TestMqlMechanicalMassClosure(self.assembly) - state = closure.initial_state_vector() - snapshot = closure.snapshot(state) - rhs = closure.rhs(state) - - self.assertEqual(closure.mass_aliases, tuple(self.assembly.masses)) - self.assertEqual(len(closure.mass_aliases), 10) - self.assertEqual(len(state), 20) - self.assertEqual(snapshot.state_count, 20) - self.assertEqual(len(rhs), 20) - self.assertEqual(snapshot.states[0].alias, "mass_friction_endstops_10") - self.assertAlmostEqual( - snapshot.states[0].velocity_m_s, - self.amesim_results.series("v1@mass_friction_endstops_10")[0], - ) - self.assertAlmostEqual( - snapshot.states[0].displacement_m, - self.amesim_results.series("x1@mass_friction_endstops_10")[0], - ) - for index, mass_state in enumerate(snapshot.states): - self.assertAlmostEqual(rhs[2 * index + 1], mass_state.velocity_m_s) - - def test_mechanical_mass_snapshot_exposes_lmechan1_node_kinematics(self) -> None: - closure = TestMqlMechanicalMassClosure(self.assembly) - state = closure.initial_state_vector() - state[16] = 0.2 - state[17] = 0.37 - state[18] = -0.3 - state[19] = -0.72 - - snapshot = closure.snapshot(state) - front = snapshot.node_kinematics_by_alias[ - "dynamic_mechanical_node_alternative_2" - ] - rear = snapshot.node_kinematics_by_alias[ - "dynamic_mechanical_node_alternative_3" - ] - - self.assertEqual(set(front.velocities_m_s), set(range(1, 9))) - self.assertEqual(set(front.displacements_m), set(range(1, 9))) - self.assertEqual(set(rear.velocities_m_s), set(range(1, 9))) - self.assertEqual(set(rear.displacements_m), set(range(1, 9))) - self.assertAlmostEqual(front.velocities_m_s[1], -0.2) - self.assertAlmostEqual(front.displacements_m[8], -0.37) - self.assertAlmostEqual(rear.velocities_m_s[1], -0.3) - self.assertAlmostEqual(rear.displacements_m[8], -0.72) - - def test_mechanical_mass_snapshot_exposes_piston_kinematics(self) -> None: - closure = TestMqlMechanicalMassClosure(self.assembly) - state = closure.initial_state_vector() - state[0] = 0.4 - state[1] = 0.37 - state[18] = -0.3 - state[19] = -0.72 - - snapshot = closure.snapshot(state) - piston = snapshot.piston_kinematics_by_alias["pn_brp2_8"] - - self.assertEqual(set(snapshot.piston_kinematics_by_alias), set(self.assembly.pistons)) - self.assertAlmostEqual(piston.port_2_velocity_m_s, 0.4) - self.assertAlmostEqual(piston.port_2_displacement_m, 0.37) - self.assertAlmostEqual(piston.port_3_velocity_m_s, -0.3) - self.assertAlmostEqual(piston.port_3_displacement_m, -0.72) - self.assertAlmostEqual( - self.assembly.pistons["pn_brp2_8"].geometry().chamber_length_mm( - piston.port_3_displacement_m, - piston.port_2_displacement_m, - ), - 1090.0, - ) - - def test_system_simulates_mechanical_mass_state_closure(self) -> None: - system = TestMqlSystem() - - solution = system.simulate_mechanical_masses( - config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), - t_eval=[0.0, 1.0e-5], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(system.mechanical_state_vector()), 20) - self.assertEqual(len(solution.t), 2) - self.assertEqual(len(solution.y), 20) - self.assertEqual([len(row) for row in solution.y], [2] * 20) - - def test_piston_geometry_is_converted_to_si_units(self) -> None: - piston = self.assembly.pistons["pn_brp2_8"] - - self.assertAlmostEqual(piston.piston_diameter_m, 0.2) - self.assertAlmostEqual(piston.rod_diameter_m, 0.001) - self.assertAlmostEqual(piston.zero_displacement_m, 0.0) - self.assertAlmostEqual(piston.piston_area_m2, circular_area(0.2)) - self.assertAlmostEqual(piston.rod_area_m2, circular_area(0.001)) - self.assertAlmostEqual(piston.annulus_area_m2, piston.piston_area_m2 - piston.rod_area_m2) - self.assertIn("vvol1@pn_brp2_8", piston.data_paths) - self.assertIn("f2@pn_brp2_8", piston.data_paths) - - def test_mass_endstop_parameters_are_converted_to_si_units(self) -> None: - mass = self.assembly.masses["mass_friction_endstops_10"] - - self.assertEqual(mass.mass_kg, 50.0) - self.assertEqual(mass.xmin_m, -1.0) - self.assertEqual(mass.xmax_m, 0.8) - self.assertEqual(mass.min_stiffness_n_per_m, 1.0e9) - self.assertEqual(mass.max_stiffness_n_per_m, 1.0e9) - self.assertEqual(mass.min_damping_n_per_m_per_s, 1.0e4) - self.assertEqual(mass.max_damping_n_per_m_per_s, 1.0e4) - self.assertAlmostEqual(mass.min_penetration_m, 1.0e-4) - self.assertAlmostEqual(mass.max_penetration_m, 1.0e-4) - self.assertEqual(mass.stiction_force_n, 0.0) - self.assertEqual(mass.coulomb_friction_n, 0.0) - self.assertEqual(mass.viscous_friction_n_per_m_per_s, 0.0) - self.assertEqual(mass.windage_n_per_m2_per_s2, 0.0) - self.assertEqual(mass.stick_velocity_threshold_m_s, 1.0e-6) - self.assertEqual(mass.reset_velocity_threshold_m_s, 1.0e-6) - self.assertEqual(mass.rest_coeff, 0.65) - self.assertEqual(mass.stribeck_constant_m_s, 1.0e-3) - self.assertFalse(mass.use_friction) - self.assertEqual(mass.stop_type, 4) - self.assertIn("v1@mass_friction_endstops_10", mass.data_paths) - self.assertIn("x1@mass_friction_endstops_10", mass.data_paths) - - def test_large_load_masses_keep_distinct_limits(self) -> None: - front = self.assembly.masses["mass_friction_endstops_18"] - rear = self.assembly.masses["mass_friction_endstops_19"] - - self.assertEqual(front.mass_kg, 170000.0) - self.assertEqual(front.xmin_m, 0.0) - self.assertEqual(front.xmax_m, 0.37) - self.assertEqual(rear.mass_kg, 90000.0) - self.assertEqual(rear.xmin_m, -0.72) - self.assertEqual(rear.xmax_m, 0.0) - - def test_elastic_endstop_parameters_are_converted_to_si_units(self) -> None: - endstop = self.assembly.elastic_endstops["elasticendstop_8"] - - self.assertEqual(endstop.gap_m, 0.0) - self.assertEqual(endstop.contact_stiffness_n_per_m, 1.0e11) - self.assertEqual(endstop.contact_damping_n_per_m_per_s, 1.0e11) - self.assertAlmostEqual(endstop.spring_diameter_m, 0.02) - self.assertAlmostEqual(endstop.wire_diameter_m, 0.002) - - def test_mechanical_nodes_keep_port_count(self) -> None: - node = self.assembly.mechanical_nodes["dynamic_mechanical_node_alternative_2"] - - self.assertEqual(node.port_count, 8) - self.assertEqual(node.sum_mode, 1) - self.assertIn("dynamic_mechanical_node_alternative_2", self.assembly.aliases) - - - def test_all_piston_geometry_observables_match_amesim_final_sample(self) -> None: - for alias, piston in self.assembly.pistons.items(): - with self.subTest(alias=alias): - geometry = piston.geometry() - x4 = self.amesim_results.series(f"x4@{alias}")[-1] - x5 = self.amesim_results.series(f"x5@{alias}")[-1] - v4 = self.amesim_results.series(f"v4@{alias}")[-1] - v5 = self.amesim_results.series(f"v5@{alias}")[-1] - - self.assertAlmostEqual( - geometry.chamber_length_mm(x4, x5), - self.amesim_results.series(f"length@{alias}")[-1], - ) - self.assertAlmostEqual( - geometry.chamber_volume_cm3(x4, x5), - self.amesim_results.series(f"vol1@{alias}")[-1], - delta=1.0e-6, - ) - self.assertAlmostEqual( - geometry.chamber_volume_rate_l_min(v4, v5), - self.amesim_results.series(f"vvol1@{alias}")[-1], - delta=1.0e-8, - ) - - def test_all_elastic_endstop_forces_match_amesim_final_sample(self) -> None: - for endstop_alias, endstop_spec in self.assembly.elastic_endstops.items(): - piston_alias = endstop_alias.replace("elasticendstop", "pn_brp2") - with self.subTest(alias=endstop_alias): - endstop = endstop_spec.endstop() - gap_mm = self.amesim_results.series(f"gap@{endstop_alias}")[-1] - penetration_velocity_m_s = self.amesim_results.series(f"v5@{piston_alias}")[-1] - - self.assertAlmostEqual( - endstop.contact_force(gap_mm, penetration_velocity_m_s), - self.amesim_results.series(f"f1@{endstop_alias}")[-1], - delta=1.0e-3, - ) - - def test_mass_endstop_second_port_observables_are_opposite_sign(self) -> None: - for alias in self.assembly.masses: - with self.subTest(alias=alias): - for signal in ("x1", "v1", "acc1"): - primary = self.amesim_results.series(f"{signal}@{alias}") - second_port = self.amesim_results.series(f"{signal}dup@{alias}") - for index in (0, len(primary) // 2, -1): - self.assertAlmostEqual(primary[index], -second_port[index]) - - def test_mass_endstop_force_observables_match_inactive_amesim_contacts(self) -> None: - for alias, mass_spec in self.assembly.masses.items(): - endstop = mass_spec.endstop() - with self.subTest(alias=alias): - x1 = self.amesim_results.series(f"x1@{alias}")[-1] - v1 = self.amesim_results.series(f"v1@{alias}")[-1] - - self.assertAlmostEqual( - endstop.viscous_friction_force(v1), - self.amesim_results.series(f"Fvisc@{alias}")[-1], - ) - self.assertAlmostEqual(self.amesim_results.series(f"Ffric@{alias}")[-1], 0.0) - self.assertAlmostEqual(self.amesim_results.series(f"Fmin@{alias}")[-1], 0.0) - self.assertAlmostEqual(self.amesim_results.series(f"Fmax@{alias}")[-1], 0.0) - self.assertLessEqual(endstop.lower_penetration_m(x1), 1.0e-12) - self.assertLessEqual(endstop.upper_penetration_m(x1), 1.0e-12) - - def test_unit_helpers(self) -> None: - self.assertAlmostEqual(mm_to_m(20.0), 0.02) - self.assertAlmostEqual(n_per_mm_to_n_per_m(1.0e6), 1.0e9) - self.assertAlmostEqual(n_per_mm_per_s_to_n_per_m_per_s(10.0), 1.0e4) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_mechanical_observations.py b/tests/test_test_mql_mechanical_observations.py deleted file mode 100644 index 21b2913..0000000 --- a/tests/test_test_mql_mechanical_observations.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_mechanical_observations import ( - build_test_mql_mechanical_observation_catalog, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlMechanicalObservationCatalogTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.catalog = build_test_mql_mechanical_observation_catalog(cls.amesim_results) - - def test_builds_expected_mechanical_observation_counts(self) -> None: - self.assertEqual(len(self.catalog.pistons), 8) - self.assertEqual(len(self.catalog.masses), 10) - self.assertEqual(len(self.catalog.elastic_endstops), 8) - self.assertEqual(len(self.catalog.zero_force_sources), 16) - self.assertEqual(len(self.catalog.force_connectors), 2) - self.assertEqual(len(self.catalog.mechanical_nodes), 2) - self.assertEqual(self.catalog.binding_count, 46) - - def test_piston_binding_exposes_amesim_data_paths(self) -> None: - binding = self.catalog.pistons["pn_brp2_8"] - - self.assertEqual(binding.volume_path, "vol1@pn_brp2_8") - self.assertEqual(binding.volume_rate_path, "vvol1@pn_brp2_8") - self.assertEqual(binding.length_path, "length@pn_brp2_8") - self.assertEqual(binding.force_port_2_path, "f2@pn_brp2_8") - self.assertEqual(binding.force_port_3_path, "f3@pn_brp2_8") - self.assertEqual(binding.displacement_port_2_path, "x5@pn_brp2_8") - self.assertEqual(binding.velocity_port_2_path, "v5@pn_brp2_8") - self.assertEqual(binding.displacement_port_3_path, "x4@pn_brp2_8") - self.assertEqual(binding.velocity_port_3_path, "v4@pn_brp2_8") - - def test_piston_observation_matches_amesim_final_sample(self) -> None: - observation = self.catalog.pistons["pn_brp2_8"].observation_at( - self.amesim_results, - -1, - ) - - self.assertAlmostEqual(observation.time, self.amesim_results.times[-1]) - self.assertAlmostEqual(observation.chamber_volume_cm3, 34242.54636512914) - self.assertAlmostEqual(observation.chamber_volume_rate_l_min, 8.360141002531034e-07) - self.assertAlmostEqual(observation.chamber_length_mm, 1090.0013536465842) - self.assertAlmostEqual(observation.displacement_port_2_m, 0.3700013536465843) - self.assertAlmostEqual(observation.velocity_port_2_m_s, 4.435303434246023e-10) - self.assertAlmostEqual(observation.displacement_port_3_m, -0.72) - self.assertAlmostEqual(observation.velocity_port_3_m_s, 0.0) - - def test_mass_binding_exposes_primary_and_reversed_duplicate_paths(self) -> None: - binding = self.catalog.masses["mass_friction_endstops_10"] - - self.assertEqual(binding.displacement_path, "x1@mass_friction_endstops_10") - self.assertEqual(binding.velocity_path, "v1@mass_friction_endstops_10") - self.assertEqual(binding.acceleration_path, "acc1@mass_friction_endstops_10") - self.assertEqual(binding.displacement_duplicate_path, "x1dup@mass_friction_endstops_10") - self.assertEqual(binding.velocity_duplicate_path, "v1dup@mass_friction_endstops_10") - self.assertEqual(binding.acceleration_duplicate_path, "acc1dup@mass_friction_endstops_10") - self.assertEqual(binding.lower_contact_force_path, "Fmin@mass_friction_endstops_10") - self.assertEqual(binding.upper_contact_force_path, "Fmax@mass_friction_endstops_10") - self.assertEqual(binding.viscous_friction_force_path, "Fvisc@mass_friction_endstops_10") - self.assertEqual(binding.dry_friction_force_path, "Ffric@mass_friction_endstops_10") - self.assertEqual(binding.stick_flag_path, "stick@mass_friction_endstops_10") - - def test_mass_observation_matches_amesim_final_sample(self) -> None: - observation = self.catalog.masses["mass_friction_endstops_10"].observation_at( - self.amesim_results, - -1, - ) - - self.assertAlmostEqual(observation.displacement_m, 0.3700013536465843) - self.assertAlmostEqual(observation.velocity_m_s, 4.435303434246023e-10) - self.assertAlmostEqual(observation.acceleration_m_s2, -3.5662378650158644e-07) - self.assertAlmostEqual(observation.lower_contact_force_n, 0.0) - self.assertAlmostEqual(observation.upper_contact_force_n, 0.0) - self.assertAlmostEqual(observation.viscous_friction_force_n, 0.0) - self.assertAlmostEqual(observation.dry_friction_force_n, 0.0) - self.assertAlmostEqual(observation.stick_flag, 0.0) - - def test_mass_duplicate_series_are_sign_reversed(self) -> None: - for binding in self.catalog.masses.values(): - with self.subTest(alias=binding.alias): - for primary_path, duplicate_path in ( - (binding.displacement_path, binding.displacement_duplicate_path), - (binding.velocity_path, binding.velocity_duplicate_path), - (binding.acceleration_path, binding.acceleration_duplicate_path), - ): - primary = self.amesim_results.series(primary_path) - duplicate = self.amesim_results.series(duplicate_path) - self.assertLess( - max(abs(a + b) for a, b in zip(primary, duplicate)), - 1.0e-12, - ) - - def test_elastic_endstop_observation_matches_amesim_final_sample(self) -> None: - observation = self.catalog.elastic_endstops["elasticendstop_8"].observation_at( - self.amesim_results, - -1, - ) - - self.assertAlmostEqual(observation.force_n, 135409.0113969468) - self.assertAlmostEqual(observation.duplicate_force_n, 135409.0113969468) - self.assertAlmostEqual(observation.gap_mm, -0.0013536465842123313) - self.assertAlmostEqual(observation.stiffness_n_m, 100000000000.0) - - def test_elastic_endstop_duplicate_force_matches_primary_series(self) -> None: - for binding in self.catalog.elastic_endstops.values(): - force = self.amesim_results.series(binding.force_path) - duplicate = self.amesim_results.series(binding.duplicate_force_path) - with self.subTest(alias=binding.alias): - self.assertLess( - max(abs(a - b) for a, b in zip(force, duplicate)), - 1.0e-9, - ) - - def test_zero_force_source_observations_are_zero(self) -> None: - for binding in self.catalog.zero_force_sources.values(): - force = self.amesim_results.series(binding.force_path) - with self.subTest(alias=binding.alias): - self.assertEqual(binding.force_path, f"fzero@{binding.alias}") - self.assertEqual(min(force), 0.0) - self.assertEqual(max(force), 0.0) - self.assertEqual(binding.observation_at(self.amesim_results, -1).force_n, 0.0) - - def test_force_connector_observations_match_amesim_samples(self) -> None: - force_1 = self.catalog.force_connectors["forcecon_1"].observation_at( - self.amesim_results, - -1, - ) - force_2 = self.catalog.force_connectors["forcecon_2"].observation_at( - self.amesim_results, - -1, - ) - - self.assertEqual(self.catalog.force_connectors["forcecon_1"].force_path, "force@forcecon_1") - self.assertEqual(self.catalog.force_connectors["forcecon_2"].force_path, "force@forcecon_2") - self.assertAlmostEqual(force_1.force_n, 49000.0) - self.assertAlmostEqual(force_2.force_n, 0.0) - self.assertAlmostEqual( - self.amesim_results.series("force@forcecon_1")[0], - 1.0e17, - ) - self.assertAlmostEqual( - self.amesim_results.series("force@forcecon_2")[0], - 1.0e12, - ) - - def test_mechanical_node_bindings_expose_all_ports(self) -> None: - node = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_2"] - - self.assertEqual(sorted(node.velocity_paths_by_port), list(range(1, 9))) - self.assertEqual(sorted(node.displacement_paths_by_port), list(range(1, 9))) - self.assertEqual(node.velocity_paths_by_port[1], "p1__vt@dynamic_mechanical_node_alternative_2") - self.assertEqual(node.displacement_paths_by_port[8], "p8__xt@dynamic_mechanical_node_alternative_2") - self.assertEqual(node.total_force_path, "tforce@dynamic_mechanical_node_alternative_2") - - def test_mechanical_node_observations_match_amesim_final_samples(self) -> None: - front = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_2"].observation_at( - self.amesim_results, - -1, - ) - rear = self.catalog.mechanical_nodes["dynamic_mechanical_node_alternative_3"].observation_at( - self.amesim_results, - -1, - ) - - self.assertEqual(set(front.velocities_m_s), set(range(1, 9))) - self.assertEqual(set(front.displacements_m), set(range(1, 9))) - self.assertAlmostEqual(front.velocities_m_s[1], -0.0) - self.assertAlmostEqual(front.displacements_m[1], -0.3700000000000001) - self.assertAlmostEqual(front.displacements_m[8], -0.3700000000000001) - self.assertAlmostEqual(front.total_force_n, 1083272.2763054767) - self.assertAlmostEqual(rear.velocities_m_s[1], 0.0) - self.assertAlmostEqual(rear.displacements_m[1], -0.72) - self.assertAlmostEqual(rear.displacements_m[8], -0.72) - self.assertAlmostEqual(rear.total_force_n, 1083272.2762592242) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_nodes.py b/tests/test_test_mql_nodes.py deleted file mode 100644 index ad98b8a..0000000 --- a/tests/test_test_mql_nodes.py +++ /dev/null @@ -1,245 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.examples.test_mql.system import TestMqlSystem -from app.simulation.examples.test_mql.nodes import ( - build_test_mql_node3_assembly, - build_test_mql_node4_assembly, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlPneumaticNode3Tests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.nodes = build_test_mql_node3_assembly() - cls.results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_builds_all_eight_pn3node2_instances(self) -> None: - self.assertEqual(len(self.nodes), 8) - self.assertIn("pn_node3_9", self.nodes) - system = TestMqlSystem() - self.assertEqual(system.typed_node3_count, 8) - self.assertEqual(system.node3_assembly["pn_node3_9"].alias, "pn_node3_9") - - def test_port_two_flow_is_exact_sum_of_ports_one_and_three(self) -> None: - balance = self.nodes["pn_node3_9"].balance( - port_2_temperature_k=281.0, - port_2_pressure_pa=4.4e6, - port_1_enthalpy_flow_w=2.0, - port_1_mass_flow_g_s=3.0, - port_3_enthalpy_flow_w=-0.5, - port_3_mass_flow_g_s=-1.0, - port_1_volume_derivative_l_min=4.0, - port_1_volume_cm3=5.0, - port_3_volume_derivative_l_min=-1.0, - port_3_volume_cm3=6.0, - ) - - self.assertEqual(balance.temperature_k, 281.0) - self.assertEqual(balance.pressure_pa, 4.4e6) - self.assertEqual(balance.port_2_enthalpy_flow_w, 1.5) - self.assertEqual(balance.port_2_mass_flow_g_s, 2.0) - self.assertEqual(balance.port_2_volume_derivative_l_min, 3.0) - self.assertEqual(balance.port_2_volume_cm3, 11.0) - - def test_pn_node3_9_baseline_matches_adjacent_line_flow_sum(self) -> None: - index = 500 - balance = self.nodes["pn_node3_9"].balance( - port_2_temperature_k=self.results.series("t1@pneumatic_88")[index], - port_2_pressure_pa=( - self.results.series("p1@pneumatic_88")[index] + 101_300.0 - ), - port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_100")[index], - port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_100")[index], - port_3_enthalpy_flow_w=self.results.series("dh1@pneumatic_97")[index], - port_3_mass_flow_g_s=self.results.series("dm1@pneumatic_97")[index], - ) - - self.assertAlmostEqual( - balance.port_2_mass_flow_g_s, - self.results.series("dm2@pn_node3_9")[index], - ) - self.assertAlmostEqual( - balance.port_2_enthalpy_flow_w, - self.results.series("dh2@pn_node3_9")[index], - ) - self.assertEqual(self.results.series("vol2@pn_node3_9")[index], 0.0) - self.assertEqual(self.results.series("dvol2@pn_node3_9")[index], 0.0) - - def test_rejects_nonphysical_primary_pressure_or_temperature(self) -> None: - node = self.nodes["pn_node3_9"] - with self.assertRaises(ValueError): - node.balance( - port_2_temperature_k=0.0, - port_2_pressure_pa=1.0e5, - port_1_enthalpy_flow_w=0.0, - port_1_mass_flow_g_s=0.0, - port_3_enthalpy_flow_w=0.0, - port_3_mass_flow_g_s=0.0, - ) - - -class TestMqlPneumaticNode4Tests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.nodes = build_test_mql_node4_assembly() - cls.results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_builds_all_eight_p4node2_instances(self) -> None: - self.assertEqual(len(self.nodes), 8) - self.assertIn("pnnode4_17", self.nodes) - system = TestMqlSystem() - self.assertEqual(system.typed_node4_count, 8) - self.assertEqual(system.node4_assembly["pnnode4_17"].alias, "pnnode4_17") - - def test_pnnode4_17_neighborhood_maps_adjacent_p4_nodes(self) -> None: - system = TestMqlSystem() - neighborhood = system.p4_node_neighborhood("pnnode4_17") - - self.assertEqual(neighborhood.node_alias, "pnnode4_17") - self.assertEqual(neighborhood.primary.line_alias, "pneumatic_68") - self.assertEqual(neighborhood.primary.chamber_alias, "pn_c1_9") - self.assertEqual(neighborhood.port_1.line_alias, "pneumatic_86") - self.assertEqual(neighborhood.port_1.remote_node_alias, "pnnode4_18") - self.assertEqual(neighborhood.port_1.remote_port, "port_3") - self.assertEqual(neighborhood.port_3.line_alias, "pneumatic_83") - self.assertEqual(neighborhood.port_3.remote_node_alias, "pnnode4_16") - self.assertEqual(neighborhood.port_3.remote_port, "port_1") - self.assertEqual(neighborhood.port_4.orifice_alias, "pn_morifice_9") - self.assertEqual(neighborhood.port_4.direct_line_alias, "pneumatic_90") - - def test_adjacent_p4_node_primary_pressures_match_their_pnl0001_states(self) -> None: - index = 500 - - self.assertAlmostEqual( - self.results.series("press1@pnnode4_18")[index], - self.results.series("p2@pneumatic_66")[index], - ) - self.assertAlmostEqual( - self.results.series("press1@pnnode4_16")[index], - self.results.series("p2@pneumatic_69")[index], - ) - - def test_adjacent_p4_node_baselines_match_adjacent_flow_sums(self) -> None: - index = 500 - pnnode4_18 = self.nodes["pnnode4_18"].balance( - port_2_temperature_k=self.results.series("temp1@pnnode4_18")[index], - port_2_pressure_pa=( - self.results.series("press1@pnnode4_18")[index] + 101_300.0 - ), - port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_80")[index], - port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_80")[index], - port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_86")[index], - port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_86")[index], - port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_10")[index], - port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_10")[index], - ) - pnnode4_16 = self.nodes["pnnode4_16"].balance( - port_2_temperature_k=self.results.series("temp1@pnnode4_16")[index], - port_2_pressure_pa=( - self.results.series("press1@pnnode4_16")[index] + 101_300.0 - ), - port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_83")[index], - port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_83")[index], - port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_95")[index], - port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_95")[index], - port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_1")[index], - port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_1")[index], - ) - - self.assertAlmostEqual( - pnnode4_18.port_2_mass_flow_g_s, - self.results.series("dm2@pnnode4_18")[index], - ) - self.assertAlmostEqual( - pnnode4_18.port_2_enthalpy_flow_w, - self.results.series("dh2@pnnode4_18")[index], - ) - self.assertAlmostEqual( - pnnode4_16.port_2_mass_flow_g_s, - self.results.series("dm2@pnnode4_16")[index], - ) - self.assertAlmostEqual( - pnnode4_16.port_2_enthalpy_flow_w, - self.results.series("dh2@pnnode4_16")[index], - ) - - def test_port_two_flow_is_exact_sum_of_ports_one_three_and_four(self) -> None: - balance = self.nodes["pnnode4_17"].balance( - port_2_temperature_k=281.0, - port_2_pressure_pa=4.4e6, - port_1_enthalpy_flow_w=2.0, - port_1_mass_flow_g_s=3.0, - port_3_enthalpy_flow_w=-0.5, - port_3_mass_flow_g_s=-1.0, - port_4_enthalpy_flow_w=4.0, - port_4_mass_flow_g_s=5.0, - port_1_volume_derivative_l_min=4.0, - port_1_volume_cm3=5.0, - port_3_volume_derivative_l_min=-1.0, - port_3_volume_cm3=6.0, - port_4_volume_derivative_l_min=2.0, - port_4_volume_cm3=7.0, - ) - - self.assertEqual(balance.temperature_k, 281.0) - self.assertEqual(balance.pressure_pa, 4.4e6) - self.assertEqual(balance.port_2_enthalpy_flow_w, 5.5) - self.assertEqual(balance.port_2_mass_flow_g_s, 7.0) - self.assertEqual(balance.port_2_volume_derivative_l_min, 5.0) - self.assertEqual(balance.port_2_volume_cm3, 18.0) - - def test_pnnode4_17_baseline_matches_adjacent_flow_sum(self) -> None: - index = 500 - balance = self.nodes["pnnode4_17"].balance( - port_2_temperature_k=self.results.series("temp1@pnnode4_17")[index], - port_2_pressure_pa=( - self.results.series("press1@pnnode4_17")[index] + 101_300.0 - ), - port_1_enthalpy_flow_w=self.results.series("dh1@pneumatic_86")[index], - port_1_mass_flow_g_s=self.results.series("dm1@pneumatic_86")[index], - port_3_enthalpy_flow_w=self.results.series("dh2@pneumatic_83")[index], - port_3_mass_flow_g_s=self.results.series("dm2@pneumatic_83")[index], - port_4_enthalpy_flow_w=self.results.series("dh2@pn_morifice_9")[index], - port_4_mass_flow_g_s=self.results.series("dm2@pn_morifice_9")[index], - ) - - self.assertAlmostEqual( - balance.port_2_mass_flow_g_s, - self.results.series("dm2@pnnode4_17")[index], - ) - self.assertAlmostEqual( - balance.port_2_enthalpy_flow_w, - self.results.series("dh2@pnnode4_17")[index], - ) - self.assertEqual(self.results.series("vol2@pnnode4_17")[index], 0.0) - self.assertEqual(self.results.series("dvol2@pnnode4_17")[index], 0.0) - self.assertAlmostEqual( - self.results.series("press1@pnnode4_17")[index], - self.results.series("p2@pneumatic_68")[index], - ) - - def test_rejects_nonphysical_primary_pressure_or_temperature(self) -> None: - node = self.nodes["pnnode4_17"] - with self.assertRaises(ValueError): - node.balance( - port_2_temperature_k=0.0, - port_2_pressure_pa=1.0e5, - port_1_enthalpy_flow_w=0.0, - port_1_mass_flow_g_s=0.0, - port_3_enthalpy_flow_w=0.0, - port_3_mass_flow_g_s=0.0, - port_4_enthalpy_flow_w=0.0, - port_4_mass_flow_g_s=0.0, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_observations.py b/tests/test_test_mql_observations.py deleted file mode 100644 index abff8e7..0000000 --- a/tests/test_test_mql_observations.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_comparison import compare_test_mql_series -from app.simulation.reporting.test_mql_observations import build_test_mql_observation_catalog - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlObservationCatalogTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.catalog = build_test_mql_observation_catalog(cls.amesim_results) - - def test_aggregates_observation_catalog_counts(self) -> None: - self.assertEqual(len(self.catalog.chambers.bindings), 12) - self.assertEqual(len(self.catalog.orifices.bindings), 16) - self.assertEqual(self.catalog.lines.line_count, 40) - self.assertEqual(self.catalog.mechanical.binding_count, 46) - self.assertEqual(self.catalog.binding_count, 114) - - def test_reports_observed_data_paths_by_domain(self) -> None: - paths_by_domain = self.catalog.data_paths_by_domain() - - self.assertEqual(set(paths_by_domain), {"chambers", "orifices", "lines", "mechanical"}) - self.assertEqual(len(paths_by_domain["chambers"]), 100) - self.assertEqual(len(paths_by_domain["orifices"]), 104) - self.assertEqual(len(paths_by_domain["lines"]), 388) - self.assertEqual(len(paths_by_domain["mechanical"]), 266) - self.assertEqual(len(self.catalog.data_paths()), 858) - self.assertIn("press@pn_general_chamber", paths_by_domain["chambers"]) - self.assertIn("dm1@pn_orifice_18", paths_by_domain["orifices"]) - self.assertIn("pctr@pneumatic_80", paths_by_domain["lines"]) - self.assertIn("tforce@dynamic_mechanical_node_alternative_2", paths_by_domain["mechanical"]) - - def test_all_observed_data_paths_exist_in_saved_amesim_results(self) -> None: - for data_path in self.catalog.data_paths(): - with self.subTest(data_path=data_path): - self.assertIn(data_path, self.amesim_results.series_by_data_path) - - def test_baseline_series_can_be_selected_for_comparison(self) -> None: - selected_paths = ( - "press@pn_general_chamber", - "dm1@pn_orifice_18", - "dm1@pneumatic_69", - "vol1@pn_brp2_8", - "tforce@dynamic_mechanical_node_alternative_2", - ) - baseline = self.catalog.baseline_series_by_data_path( - self.amesim_results, - selected_paths, - ) - - self.assertEqual(tuple(baseline), selected_paths) - self.assertEqual(len(baseline["press@pn_general_chamber"]), len(self.amesim_results.times)) - self.assertAlmostEqual(baseline["vol1@pn_brp2_8"][-1], 34242.54636512914) - - def test_baseline_series_rejects_unobserved_data_path(self) -> None: - with self.assertRaises(KeyError): - self.catalog.baseline_series_by_data_path( - self.amesim_results, - ("not_a_real_signal@not_a_real_owner",), - ) - - def test_observation_baseline_round_trips_through_comparison_with_zero_error(self) -> None: - selected_paths = ( - "press@pn_c1_8", - "dm2@pn_morifice_11", - "dmctr@pneumatic_75", - "x1@mass_friction_endstops_10", - ) - baseline = self.catalog.baseline_series_by_data_path( - self.amesim_results, - selected_paths, - ) - comparison = compare_test_mql_series( - python_times=self.amesim_results.times, - python_series_by_data_path=baseline, - amesim_results=self.amesim_results, - data_paths=selected_paths, - ) - - self.assertEqual(len(comparison.metrics), len(selected_paths)) - self.assertEqual(comparison.max_abs_error, 0.0) - self.assertEqual(comparison.max_rel_error, 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_orifice_observations.py b/tests/test_test_mql_orifice_observations.py deleted file mode 100644 index 98f2611..0000000 --- a/tests/test_test_mql_orifice_observations.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_orifice_observations import ( - G_PER_S_TO_KG_PER_S, - build_test_mql_orifice_observation_catalog, -) -from app.simulation.examples.test_mql.pneumatic import ( - TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlOrificeObservationCatalogTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.catalog = build_test_mql_orifice_observation_catalog(cls.amesim_results) - - def test_builds_expected_orifice_observation_bindings(self) -> None: - self.assertEqual(len(self.catalog.bindings), 16) - self.assertEqual(self.catalog.fixed_count, 8) - self.assertEqual(self.catalog.variable_count, 8) - - fixed = self.catalog.by_alias("pn_orifice_18") - variable = self.catalog.by_alias("pn_morifice_11") - - self.assertEqual(fixed.submodel, "PNOR001") - self.assertEqual(fixed.primary_mass_flow_path, "dm1@pn_orifice_18") - self.assertEqual(fixed.reversed_mass_flow_path, "dm2@pn_orifice_18") - self.assertEqual(fixed.primary_enthalpy_flow_path, "dh1@pn_orifice_18") - self.assertEqual(fixed.reversed_enthalpy_flow_path, "dh2@pn_orifice_18") - self.assertIsNone(fixed.opening_path) - self.assertEqual(variable.submodel, "PNVO001") - self.assertEqual(variable.primary_mass_flow_path, "dm2@pn_morifice_11") - self.assertEqual(variable.reversed_mass_flow_path, "dm3@pn_morifice_11") - self.assertEqual(variable.opening_path, "xv@pn_morifice_11") - - def test_fixed_orifice_opening_defaults_to_full_area(self) -> None: - binding = self.catalog.by_alias("pn_orifice_18") - opening = binding.opening_series(self.amesim_results) - effective_area = binding.effective_area_series(self.amesim_results) - - self.assertEqual(opening[0], 1.0) - self.assertEqual(opening[-1], 1.0) - self.assertAlmostEqual(effective_area[0], 78.5e-6) - self.assertAlmostEqual(effective_area[-1], 78.5e-6) - - def test_variable_orifice_opening_controls_effective_area(self) -> None: - binding = self.catalog.by_alias("pn_morifice_11") - first = binding.observation_at(self.amesim_results, 0) - last = binding.observation_at(self.amesim_results, -1) - - self.assertAlmostEqual(binding.nominal_area_m2, 78.5e-6) - self.assertAlmostEqual( - binding.flow_coefficient, - 0.45 * TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, - ) - self.assertEqual(first.opening, 0.0) - self.assertEqual(first.effective_area_m2, 0.0) - self.assertEqual(last.opening, 1.0) - self.assertAlmostEqual(last.effective_area_m2, 78.5e-6) - - def test_mass_flow_series_is_converted_to_si_units(self) -> None: - binding = self.catalog.by_alias("pn_orifice_18") - mass_flow = binding.mass_flow_kg_s_series(self.amesim_results) - - self.assertAlmostEqual( - mass_flow[-1], - self.amesim_results.series("dm1@pn_orifice_18")[-1] * G_PER_S_TO_KG_PER_S, - ) - self.assertAlmostEqual(mass_flow[-1], 9.097746783809915e-6) - self.assertAlmostEqual(min(mass_flow), -0.4462054699031737) - - def test_reversed_duplicate_mass_and_enthalpy_flow_signs_match_amesim(self) -> None: - for binding in self.catalog.bindings: - primary_mass = self.amesim_results.series(binding.primary_mass_flow_path) - reversed_mass = self.amesim_results.series(binding.reversed_mass_flow_path) - primary_enthalpy = self.amesim_results.series(binding.primary_enthalpy_flow_path) - reversed_enthalpy = self.amesim_results.series(binding.reversed_enthalpy_flow_path) - with self.subTest(alias=binding.alias): - self.assertEqual(len(primary_mass), len(reversed_mass)) - self.assertEqual(len(primary_enthalpy), len(reversed_enthalpy)) - self.assertLess( - max(abs(a + b) for a, b in zip(primary_mass, reversed_mass)), - 1.0e-12, - ) - self.assertLess( - max(abs(a + b) for a, b in zip(primary_enthalpy, reversed_enthalpy)), - 1.0e-9, - ) - - def test_observation_exposes_amesim_cm_and_gas_velocity(self) -> None: - fixed = self.catalog.by_alias("pn_orifice_18").observation_at(self.amesim_results, -1) - variable = self.catalog.by_alias("pn_morifice_11").observation_at(self.amesim_results, -1) - - self.assertAlmostEqual(fixed.mass_flow_parameter, 4.831077004227076e-07) - self.assertAlmostEqual(fixed.gas_velocity_m_s, 0.016760630907703977) - self.assertAlmostEqual(variable.mass_flow_parameter, 9.302013872202105e-07) - self.assertAlmostEqual(variable.gas_velocity_m_s, -0.03277314382613749) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_output_schema.py b/tests/test_test_mql_output_schema.py deleted file mode 100644 index e682bac..0000000 --- a/tests/test_test_mql_output_schema.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_observations import build_test_mql_observation_catalog -from app.simulation.reporting.test_mql_output_schema import build_test_mql_output_schema - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlOutputSchemaTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.observation_catalog = build_test_mql_observation_catalog(cls.amesim_results) - cls.schema = build_test_mql_output_schema( - cls.amesim_results, - observation_catalog=cls.observation_catalog, - ) - - def test_schema_contains_observed_output_data_paths(self) -> None: - self.assertEqual(self.schema.signal_count, 858) - self.assertEqual(self.schema.data_paths(), self.observation_catalog.data_paths()) - self.assertEqual(len(set(self.schema.data_paths())), self.schema.signal_count) - - def test_counts_by_domain_owner_kind_and_submodel(self) -> None: - self.assertEqual( - self.schema.counts_by_domain(), - {"chambers": 100, "lines": 388, "mechanical": 266, "orifices": 104}, - ) - self.assertEqual( - self.schema.counts_by_owner_kind(), - {"component": 470, "connection": 388}, - ) - self.assertEqual(self.schema.counts_by_submodel()["PNCH012"], 80) - self.assertEqual(self.schema.counts_by_submodel()["PNCH023"], 20) - self.assertEqual(self.schema.counts_by_submodel()["PNL0001"], 180) - self.assertEqual(self.schema.counts_by_submodel()["MECMAS21"], 110) - self.assertEqual(self.schema.counts_by_submodel()["LMECHN1"], 34) - - def test_counts_by_units_preserve_amesim_units(self) -> None: - counts = self.schema.counts_by_units() - - self.assertEqual(counts["m/s"], 108) - self.assertEqual(counts[None], 98) - self.assertEqual(counts["N"], 92) - self.assertEqual(counts["J/s"], 84) - self.assertEqual(counts["g/s"], 84) - self.assertEqual(counts["Pa"], 84) - self.assertEqual(counts["K"], 84) - self.assertEqual(counts["cm**3"], 16) - self.assertEqual(counts["L/min"], 8) - - def test_typical_signal_metadata_is_available_by_data_path(self) -> None: - chamber = self.schema.by_data_path("press@pn_general_chamber") - line = self.schema.by_data_path("dm1@pneumatic_69") - mechanical = self.schema.by_data_path("tforce@dynamic_mechanical_node_alternative_2") - - self.assertEqual(chamber.domain, "chambers") - self.assertEqual(chamber.owner_alias, "pn_general_chamber") - self.assertEqual(chamber.owner_kind, "component") - self.assertEqual(chamber.submodel, "PNCH023") - self.assertEqual(chamber.signal_name, "press") - self.assertEqual(chamber.units, "Pa") - self.assertTrue(chamber.saved) - self.assertEqual(line.domain, "lines") - self.assertEqual(line.owner_kind, "connection") - self.assertEqual(line.submodel, "PNL0001") - self.assertEqual(line.units, "g/s") - self.assertEqual(mechanical.domain, "mechanical") - self.assertEqual(mechanical.submodel, "LMECHN1") - self.assertEqual(mechanical.units, "N") - - def test_data_paths_can_be_selected_by_domain(self) -> None: - self.assertEqual(len(self.schema.data_paths_by_domain("chambers")), 100) - self.assertEqual(len(self.schema.data_paths_by_domain("orifices")), 104) - self.assertEqual(len(self.schema.data_paths_by_domain("lines")), 388) - self.assertEqual(len(self.schema.data_paths_by_domain("mechanical")), 266) - self.assertEqual(self.schema.data_paths_by_domain("missing"), ()) - - def test_unknown_data_path_raises_key_error(self) -> None: - with self.assertRaises(KeyError): - self.schema.by_data_path("not_a_signal@not_an_owner") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_output_validation.py b/tests/test_test_mql_output_validation.py deleted file mode 100644 index eb42218..0000000 --- a/tests/test_test_mql_output_validation.py +++ /dev/null @@ -1,181 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_observations import build_test_mql_observation_catalog -from app.simulation.reporting.test_mql_output_schema import build_test_mql_output_schema -from app.simulation.reporting.test_mql_output_validation import ( - TestMqlOutputValidationError, - compare_validated_test_mql_output, - validate_test_mql_output, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlOutputValidationTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.observation_catalog = build_test_mql_observation_catalog(cls.amesim_results) - cls.schema = build_test_mql_output_schema( - cls.amesim_results, - observation_catalog=cls.observation_catalog, - ) - - def test_validates_selected_python_output_series(self) -> None: - times = [0.0, 0.1, 0.2] - series = { - "press@pn_c1_8": [1.0, 2.0, 3.0], - "vol1@pn_brp2_8": [4, 5, 6], - } - - validated = validate_test_mql_output( - times=times, - series_by_data_path=series, - schema=self.schema, - ) - - self.assertEqual(validated.times, (0.0, 0.1, 0.2)) - self.assertEqual(validated.data_paths, ("press@pn_c1_8", "vol1@pn_brp2_8")) - self.assertEqual(validated.series("press@pn_c1_8"), (1.0, 2.0, 3.0)) - self.assertEqual(validated.series("vol1@pn_brp2_8"), (4.0, 5.0, 6.0)) - - def test_can_validate_explicit_data_path_order(self) -> None: - validated = validate_test_mql_output( - times=(0.0, 0.1), - series_by_data_path={ - "press@pn_c1_8": (10.0, 11.0), - "vol1@pn_brp2_8": (20.0, 21.0), - }, - schema=self.schema, - data_paths=("vol1@pn_brp2_8", "press@pn_c1_8"), - ) - - self.assertEqual(validated.data_paths, ("vol1@pn_brp2_8", "press@pn_c1_8")) - - def test_rejects_output_paths_outside_schema_by_default(self) -> None: - with self.assertRaisesRegex(TestMqlOutputValidationError, "outside test_mql schema"): - validate_test_mql_output( - times=(0.0,), - series_by_data_path={"not_a_signal@not_an_owner": (1.0,)}, - schema=self.schema, - ) - - def test_can_ignore_extra_output_paths_when_requested(self) -> None: - validated = validate_test_mql_output( - times=(0.0,), - series_by_data_path={ - "press@pn_c1_8": (1.0,), - "not_a_signal@not_an_owner": (2.0,), - }, - schema=self.schema, - allow_extra_paths=True, - ) - - self.assertEqual(validated.data_paths, ("press@pn_c1_8",)) - - def test_rejects_missing_selected_data_path(self) -> None: - with self.assertRaisesRegex(TestMqlOutputValidationError, "missing selected"): - validate_test_mql_output( - times=(0.0,), - series_by_data_path={"press@pn_c1_8": (1.0,)}, - schema=self.schema, - data_paths=("press@pn_c1_8", "vol1@pn_brp2_8"), - ) - - def test_rejects_incomplete_full_schema_output(self) -> None: - with self.assertRaisesRegex(TestMqlOutputValidationError, "missing required"): - validate_test_mql_output( - times=(0.0,), - series_by_data_path={"press@pn_c1_8": (1.0,)}, - schema=self.schema, - require_all_schema_paths=True, - ) - - def test_rejects_length_mismatch(self) -> None: - with self.assertRaisesRegex(TestMqlOutputValidationError, "length mismatch"): - validate_test_mql_output( - times=(0.0, 0.1), - series_by_data_path={"press@pn_c1_8": (1.0,)}, - schema=self.schema, - ) - - def test_rejects_non_finite_values(self) -> None: - with self.assertRaisesRegex(TestMqlOutputValidationError, "non-finite"): - validate_test_mql_output( - times=(0.0,), - series_by_data_path={"press@pn_c1_8": (float("nan"),)}, - schema=self.schema, - ) - - def test_rejects_non_monotonic_time_axis(self) -> None: - with self.assertRaisesRegex(TestMqlOutputValidationError, "monotonically increasing"): - validate_test_mql_output( - times=(0.0, 0.2, 0.1), - series_by_data_path={"press@pn_c1_8": (1.0, 2.0, 3.0)}, - schema=self.schema, - ) - - def test_compares_validated_amesim_baseline_with_zero_error(self) -> None: - selected_paths = ( - "press@pn_c1_8", - "dm2@pn_morifice_11", - "x1@mass_friction_endstops_10", - ) - baseline = self.observation_catalog.baseline_series_by_data_path( - self.amesim_results, - selected_paths, - ) - - comparison = compare_validated_test_mql_output( - times=self.amesim_results.times, - series_by_data_path=baseline, - schema=self.schema, - amesim_results=self.amesim_results, - data_paths=selected_paths, - ) - - self.assertEqual(len(comparison.metrics), len(selected_paths)) - self.assertEqual(comparison.max_abs_error, 0.0) - self.assertEqual(comparison.max_rel_error, 0.0) - - def test_compares_validated_output_and_reports_perturbation_error(self) -> None: - selected_paths = ("press@pn_c1_8",) - baseline = self.observation_catalog.baseline_series_by_data_path( - self.amesim_results, - selected_paths, - ) - perturbed = dict(baseline) - values = list(perturbed["press@pn_c1_8"]) - values[-1] += 10.0 - perturbed["press@pn_c1_8"] = tuple(values) - - comparison = compare_validated_test_mql_output( - times=self.amesim_results.times, - series_by_data_path=perturbed, - schema=self.schema, - amesim_results=self.amesim_results, - data_paths=selected_paths, - ) - - metric = comparison.metric("press@pn_c1_8") - self.assertAlmostEqual(metric.final_abs_error, 10.0) - self.assertAlmostEqual(metric.max_abs_error, 10.0) - - def test_compare_validated_output_reuses_schema_validation(self) -> None: - with self.assertRaisesRegex(TestMqlOutputValidationError, "outside test_mql schema"): - compare_validated_test_mql_output( - times=(0.0,), - series_by_data_path={"not_a_signal@not_an_owner": (1.0,)}, - schema=self.schema, - amesim_results=self.amesim_results, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_pneumatic.py b/tests/test_test_mql_pneumatic.py deleted file mode 100644 index 7b45067..0000000 --- a/tests/test_test_mql_pneumatic.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.primitives.pneumatic import HELIUM_PNEUMATIC_GAS, cm3_to_m3 -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.examples.test_mql.pneumatic import ( - TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, - absolute_pressure_from_amesim_bar_parameter, - build_test_mql_pneumatic_assembly, - pressure_from_amesim_bar_parameter, - pressure_to_amesim_gauge_pa, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlPneumaticAssemblyTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_assembles_expected_amesim_pneumatic_component_counts(self) -> None: - assembly = build_test_mql_pneumatic_assembly() - - self.assertEqual(len(assembly.fixed_chambers), 4) - self.assertEqual(len(assembly.variable_chambers), 8) - self.assertEqual(len(assembly.fixed_orifices), 8) - self.assertEqual(len(assembly.variable_orifices), 8) - self.assertEqual(assembly.component_count, 28) - self.assertIn("pn_general_chamber", assembly.aliases) - self.assertIn("pn_c1_8", assembly.aliases) - self.assertIn("pn_orifice_18", assembly.aliases) - self.assertIn("pn_morifice_11", assembly.aliases) - - def test_uses_amesim_pressure_conventions(self) -> None: - assembly = build_test_mql_pneumatic_assembly() - - self.assertAlmostEqual(absolute_pressure_from_amesim_bar_parameter(153.0), 15300000.0) - self.assertAlmostEqual(pressure_from_amesim_bar_parameter(153.0), 15198700.0) - self.assertAlmostEqual(pressure_to_amesim_gauge_pa(100000.0), -1300.0) - self.assertAlmostEqual(assembly.fixed_initial_absolute_pressure_pa, 15300000.0) - self.assertAlmostEqual(assembly.fixed_initial_gauge_pressure_pa, 15198700.0) - self.assertAlmostEqual(assembly.variable_initial_absolute_pressure_pa, 100000.0) - self.assertAlmostEqual(assembly.variable_initial_gauge_pressure_pa, -1300.0) - self.assertAlmostEqual(assembly.initial_pressure_pa, 15198700.0) - - def test_chamber_parameters_are_resolved_in_si_units(self) -> None: - assembly = build_test_mql_pneumatic_assembly() - fixed_chamber = assembly.fixed_chambers["pn_general_chamber"] - variable_chamber = assembly.variable_chambers["pn_c1_8"] - - self.assertAlmostEqual(fixed_chamber.volume, 0.057) - self.assertAlmostEqual(variable_chamber.dead_volume, 0.015) - self.assertAlmostEqual(variable_chamber.volume, 0.015) - self.assertAlmostEqual(variable_chamber.heat_transfer_coefficient, 1500.0) - self.assertAlmostEqual(variable_chamber.heat_transfer_area, 0.7) - self.assertAlmostEqual(variable_chamber.external_temperature, 293.15) - self.assertIs(fixed_chamber.gas, HELIUM_PNEUMATIC_GAS) - self.assertIs(variable_chamber.gas, HELIUM_PNEUMATIC_GAS) - self.assertAlmostEqual(fixed_chamber.properties().T, 293.15) - self.assertAlmostEqual(variable_chamber.properties().T, 293.15) - - def test_orifice_parameters_are_resolved_in_si_units(self) -> None: - assembly = build_test_mql_pneumatic_assembly() - fixed_orifice = assembly.fixed_orifices["pn_orifice_18"] - variable_orifice = assembly.variable_orifices["pn_morifice_11"] - - self.assertAlmostEqual(fixed_orifice.area, 78.5e-6) - self.assertAlmostEqual(fixed_orifice.flow_coefficient, 0.9) - self.assertAlmostEqual(variable_orifice.area, 78.5e-6) - self.assertAlmostEqual( - variable_orifice.flow_coefficient, - 0.45 * TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER, - ) - self.assertAlmostEqual(variable_orifice.opening, 0.0) - self.assertEqual(assembly.variable_orifice_control_count, 8) - control = assembly.variable_orifice_controls["pn_morifice_11"] - self.assertEqual(control.step.alias, "step_8") - self.assertAlmostEqual(control.opening_at(0.0), 0.0) - self.assertAlmostEqual(control.opening_at(0.039), 0.0) - self.assertAlmostEqual(control.opening_at(0.04), 1.0) - assembly.set_variable_orifice_openings(0.04) - self.assertAlmostEqual(variable_orifice.opening, 1.0) - - def test_chamber_initial_observables_match_amesim_results(self) -> None: - assembly = build_test_mql_pneumatic_assembly() - fixed_chamber = assembly.fixed_chambers["pn_general_chamber"] - variable_chamber = assembly.variable_chambers["pn_c1_8"] - - self.assertAlmostEqual( - fixed_chamber.pressure_gauge_pa(), - self.amesim_results.series("press@pn_general_chamber")[0], - delta=1.0e-8, - ) - self.assertAlmostEqual( - variable_chamber.pressure_gauge_pa(), - self.amesim_results.series("press@pn_c1_8")[0], - delta=1.0e-8, - ) - self.assertAlmostEqual( - variable_chamber.volume_cm3(), - self.amesim_results.series("vol@pn_c1_8")[0], - ) - self.assertAlmostEqual( - variable_chamber.gas_mass_g(), - self.amesim_results.series("mgas1@pn_c1_8")[0], - delta=1.0e-4, - ) - - def test_variable_chamber_volume_matches_piston_volume_observable(self) -> None: - assembly = build_test_mql_pneumatic_assembly() - for chamber_alias, chamber in assembly.variable_chambers.items(): - piston_alias = chamber_alias.replace("pn_c1", "pn_brp2") - with self.subTest(alias=chamber_alias): - piston_volume_cm3 = self.amesim_results.series(f"vol1@{piston_alias}")[-1] - chamber.set_external_volume_m3(cm3_to_m3(piston_volume_cm3)) - self.assertAlmostEqual( - chamber.volume_cm3(), - self.amesim_results.series(f"vol@{chamber_alias}")[-1], - delta=1.0e-6, - ) - - def test_assembled_orifice_can_compute_helium_mass_flow(self) -> None: - assembly = build_test_mql_pneumatic_assembly() - fixed_orifice = assembly.fixed_orifices["pn_orifice_18"] - - flow = fixed_orifice.mass_flow(15.0e6, 10.0e6, 293.15) - - self.assertGreater(flow, 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_pnl0001.py b/tests/test_test_mql_pnl0001.py deleted file mode 100644 index 00bf86b..0000000 --- a/tests/test_test_mql_pnl0001.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.examples.test_mql.line_parameters import load_test_mql_pnl0001_specs -from app.simulation.examples.test_mql.pneumatic_lines import ( - TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE, - build_test_mql_pnl0001_assembly, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlPnl0001Tests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.specs = load_test_mql_pnl0001_specs(TEST_MQL_AME) - cls.assembly = build_test_mql_pnl0001_assembly(TEST_MQL_AME) - cls.results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_loads_all_real_pnl0001_parameters_from_cir(self) -> None: - self.assertEqual(len(self.specs), 20) - spec = self.assembly.spec("pneumatic_96") - - self.assertEqual(spec.source_component, "pn_node3_8") - self.assertEqual(spec.target_component, "pn_orifice_18") - self.assertEqual(spec.diameter_mm, 14.0) - self.assertEqual(spec.length_m, 1.0) - self.assertAlmostEqual(spec.relative_roughness, 0.045 / 14.0) - self.assertEqual(spec.polytropic_constant, 1.35) - self.assertEqual(spec.heat_transfer_coefficient, 0.0) - self.assertEqual(spec.external_temperature_k, 293.15) - self.assertEqual(spec.gas_type_index, 1) - self.assertEqual(spec.mode, 2) - self.assertAlmostEqual(spec.initial_gauge_pressure_pa, 15_198_700.0) - self.assertAlmostEqual(spec.initial_absolute_pressure_pa, 15_300_000.0) - - def test_builds_twenty_two_state_physical_line_components(self) -> None: - self.assertEqual(len(self.assembly.lines), 20) - self.assertTrue( - all(len(line.get_state_vector()) == 2 for line in self.assembly.lines.values()) - ) - - def test_calibrates_primary_d20_chamber_lines_only(self) -> None: - calibrated_aliases = { - alias - for alias, line in self.assembly.lines.items() - if line.calibrated_linear_conductance is not None - } - - self.assertEqual( - calibrated_aliases, - { - "pneumatic_65", - "pneumatic_66", - "pneumatic_68", - "pneumatic_69", - "pneumatic_71", - "pneumatic_72", - "pneumatic_73", - "pneumatic_74", - }, - ) - for alias in calibrated_aliases: - self.assertAlmostEqual( - self.assembly.lines[alias].calibrated_linear_conductance, - TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE, - ) - self.assertIsNone( - self.assembly.lines["pneumatic_70"].calibrated_linear_conductance - ) - self.assertIsNone( - self.assembly.lines["pneumatic_96"].calibrated_linear_conductance - ) - - def test_pneumatic_96_initial_observables_match_amesim_baseline(self) -> None: - pipe = self.assembly.lines["pneumatic_96"] - - self.assertAlmostEqual( - pipe.properties().p - 101_300.0, - self.results.series("p2@pneumatic_96")[0], - delta=1.0e-5, - ) - self.assertAlmostEqual( - pipe.properties().T, - self.results.series("t2@pneumatic_96")[0], - ) - self.assertAlmostEqual( - pipe.gas_mass_g(), - self.results.series("mgas@pneumatic_96")[0], - delta=0.005, - ) - - def test_baseline_mass_balance_identifies_port_two_input_sign(self) -> None: - mass = self.results.series("mgas@pneumatic_96") - dm1 = self.results.series("dm1@pneumatic_96") - dm2_peer = self.results.series("dm2@pn_orifice_18") - index = 500 - finite_difference = (mass[index] - mass[index - 1]) / ( - self.results.times[index] - self.results.times[index - 1] - ) - - self.assertAlmostEqual( - finite_difference, - dm2_peer[index] - dm1[index], - delta=3.0e-5, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_pnl0001_segment.py b/tests/test_test_mql_pnl0001_segment.py deleted file mode 100644 index 5ab6595..0000000 --- a/tests/test_test_mql_pnl0001_segment.py +++ /dev/null @@ -1,1564 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.solver import SolveIVPConfig -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_output_schema import build_test_mql_output_schema -from app.simulation.reporting.test_mql_output_validation import compare_validated_test_mql_output -from app.simulation.examples.test_mql.system import TestMqlSystem - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlPnl0001ChamberSegmentTests(unittest.TestCase): - def setUp(self) -> None: - self.system = TestMqlSystem() - self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] - - def test_system_assembles_all_twenty_physical_pnl0001_lines(self) -> None: - self.assertEqual(self.system.typed_pnl0001_line_count, 20) - self.assertIn("pneumatic_96", self.system.pnl0001_assembly.lines) - - def test_closure_inserts_topology_derived_inlet_line(self) -> None: - closure = self.system.pnl0001_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=16.0e6, - outlet_pressure_pa=15.3e6, - ) - - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") - self.assertEqual(len(closure.initial_state_vector()), 4) - self.assertEqual(len(rhs), 4) - self.assertGreater(snapshot.node_to_line_flow, 0.0) - self.assertAlmostEqual(snapshot.line_to_chamber_flow, 0.0, delta=1.0e-7) - self.assertAlmostEqual(snapshot.outlet_flow, 0.0, delta=1.0e-7) - self.assertAlmostEqual(rhs[0] + rhs[2], snapshot.node_to_line_flow) - - def test_internal_line_orifice_flow_cancels_from_total_mass_balance(self) -> None: - closure = self.system.pnl0001_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.3e6, - outlet_pressure_pa=14.0e6, - ) - state = closure.initial_state_vector() - state[0] *= 1.01 - state[1] *= 1.01 - - snapshot = closure.snapshot(state) - rhs = closure.rhs(state) - - self.assertGreater(snapshot.line_to_chamber_flow, 0.0) - self.assertAlmostEqual( - rhs[0] + rhs[2], - snapshot.node_to_line_flow - snapshot.outlet_flow, - delta=1.0e-12, - ) - self.assertAlmostEqual( - closure.components.inlet_line.port_2.m_flow, - -snapshot.line_to_chamber_flow, - ) - - def test_simulates_four_state_line_chamber_segment(self) -> None: - solution = self.system.simulate_pnl0001_chamber_segment_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - outlet_pressure_pa=15.29e6, - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0e-7, - max_step=1.0e-8, - ), - t_eval=[0.0, 5.0e-8, 1.0e-7], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(solution.t), 3) - self.assertEqual(len(solution.y), 4) - self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3]) - - -class TestMqlPnl0001PairChamberSegmentTests(unittest.TestCase): - def setUp(self) -> None: - self.system = TestMqlSystem() - self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] - - def test_pair_closure_uses_both_topology_derived_lines(self) -> None: - closure = self.system.pnl0001_pair_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=16.0e6, - outlet_node_pressure_pa=14.0e6, - ) - - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") - self.assertEqual(closure.components.outlet_line.name, "pneumatic_97") - self.assertEqual(len(closure.initial_state_vector()), 6) - self.assertEqual(len(rhs), 6) - self.assertGreater(snapshot.inlet_node_to_line_flow, 0.0) - self.assertLess(snapshot.outlet_node_to_line_flow, 0.0) - self.assertAlmostEqual( - rhs[0] + rhs[2] + rhs[4], - snapshot.inlet_node_to_line_flow + snapshot.outlet_node_to_line_flow, - delta=1.0e-12, - ) - - def test_both_internal_orifice_flows_cancel_from_total_mass(self) -> None: - closure = self.system.pnl0001_pair_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.3e6, - outlet_node_pressure_pa=15.3e6, - ) - state = closure.initial_state_vector() - state[0] *= 1.01 - state[1] *= 1.01 - state[4] *= 0.99 - state[5] *= 0.99 - - snapshot = closure.snapshot(state) - rhs = closure.rhs(state) - - self.assertGreater(snapshot.inlet_line_to_chamber_flow, 0.0) - self.assertLess(snapshot.outlet_line_to_chamber_flow, 0.0) - self.assertAlmostEqual( - rhs[0] + rhs[2] + rhs[4], - snapshot.inlet_node_to_line_flow + snapshot.outlet_node_to_line_flow, - delta=1.0e-12, - ) - self.assertAlmostEqual( - closure.components.outlet_line.port_2.m_flow, - -snapshot.outlet_line_to_chamber_flow, - ) - - def test_simulates_six_state_pair_line_chamber_segment(self) -> None: - solution = self.system.simulate_pnl0001_pair_chamber_segment_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - outlet_node_pressure_pa=15.29e6, - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0e-7, - max_step=1.0e-8, - ), - t_eval=[0.0, 5.0e-8, 1.0e-7], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(solution.t), 3) - self.assertEqual(len(solution.y), 6) - self.assertEqual([len(row) for row in solution.y], [3, 3, 3, 3, 3, 3]) - - -class TestMqlPn3NodeChamberSegmentTests(unittest.TestCase): - def setUp(self) -> None: - self.system = TestMqlSystem() - self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] - - def test_closure_replaces_outlet_boundary_with_real_pn3_node(self) -> None: - closure = self.system.pn3_node_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") - self.assertEqual(closure.components.outlet_line.name, "pneumatic_97") - self.assertEqual(closure.components.node_line.name, "pneumatic_88") - self.assertEqual(closure.components.node_resistance.name, "pneumatic_100") - self.assertEqual(closure.components.node_orifice.name, "pn_morifice_9") - self.assertEqual(closure.components.node.alias, "pn_node3_9") - self.assertEqual(len(closure.initial_state_vector()), 10) - self.assertEqual(len(rhs), 10) - self.assertAlmostEqual( - snapshot.node_balance.pressure_pa, - snapshot.node_line_port_1.p, - ) - self.assertAlmostEqual( - snapshot.node_balance.temperature_k, - snapshot.node_line_port_1.T, - ) - - def test_pn3_node_flow_cancels_between_adjacent_lines(self) -> None: - closure = self.system.pn3_node_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.3e6, - resistance_boundary_pressure_pa=15.0e6, - p4_boundary_pressure_pa=15.0e6, - ) - state = closure.initial_state_vector() - state[4] *= 0.99 - state[5] *= 0.99 - self.assertAlmostEqual(closure.components.node_orifice.opening, 0.0) - closure.components.node_orifice.opening = 1.0 - - snapshot = closure.snapshot(state) - rhs = closure.rhs(state) - - self.assertGreater(snapshot.outlet_node_to_line_flow, 0.0) - self.assertGreater(snapshot.node_to_resistance_flow, 0.0) - self.assertAlmostEqual( - snapshot.node_balance.port_2_mass_flow_g_s, - (snapshot.outlet_node_to_line_flow + snapshot.node_to_resistance_flow) - * 1.0e3, - ) - self.assertAlmostEqual( - snapshot.node_to_pnl0003_flow, - -(snapshot.outlet_node_to_line_flow + snapshot.node_to_resistance_flow), - ) - self.assertGreater(snapshot.pnl0003_to_p4_flow, 0.0) - self.assertAlmostEqual( - closure.components.node_line.port_1.m_flow, - snapshot.node_to_pnl0003_flow, - ) - self.assertAlmostEqual( - closure.components.node_line.port_2.m_flow, - -snapshot.pnl0003_to_p4_flow, - ) - mass_derivative_sum = rhs[0] + rhs[2] + rhs[4] + rhs[6] + rhs[8] - self.assertAlmostEqual( - mass_derivative_sum, - ( - snapshot.inlet_node_to_line_flow - - snapshot.pnl0003_to_p4_flow - - snapshot.node_to_resistance_flow - ), - delta=1.0e-12, - ) - - def test_simulates_ten_state_pn3_node_chamber_segment(self) -> None: - solution = self.system.simulate_pn3_node_chamber_segment_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - p4_boundary_pressure_pa=15.29e6, - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0e-8, - max_step=1.0e-9, - ), - t_eval=[0.0, 5.0e-9, 1.0e-8], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(solution.t), 3) - self.assertEqual(len(solution.y), 10) - self.assertEqual([len(row) for row in solution.y], [3] * 10) - - -class TestMqlPn3P4NodeChamberSegmentTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.output_schema = build_test_mql_output_schema(cls.amesim_results) - - def setUp(self) -> None: - self.system = TestMqlSystem() - self.spec = self.system.discover_pneumatic_branch_topology().chamber_segment_specs[0] - - def test_closure_replaces_p4_pressure_boundary_with_real_node_and_lines(self) -> None: - closure = self.system.pn3_p4_node_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertEqual(closure.components.inlet_line.name, "pneumatic_96") - self.assertEqual(closure.components.volume.name, "pn_general_chamber") - self.assertEqual(closure.components.outlet_line.name, "pneumatic_97") - self.assertEqual(closure.components.inlet_orifice.name, "pn_orifice_18") - self.assertEqual(closure.components.outlet_orifice.name, "pn_orifice_19") - self.assertEqual(closure.components.node_orifice.name, "pn_morifice_9") - self.assertEqual(closure.components.node_resistance.name, "pneumatic_100") - self.assertEqual(closure.components.node.alias, "pn_node3_9") - self.assertEqual(closure.components.p4_node.alias, "pnnode4_17") - self.assertEqual(closure.components.p4_primary_line.name, "pneumatic_68") - self.assertEqual(closure.components.p4_primary_chamber.name, "pn_c1_9") - self.assertEqual(closure.components.p4_port1_line.name, "pneumatic_86") - self.assertEqual(closure.components.p4_port1_remote_node.alias, "pnnode4_18") - self.assertEqual( - closure.components.p4_port1_remote_primary_line.name, - "pneumatic_66", - ) - self.assertEqual( - closure.components.p4_port1_remote_primary_chamber.name, - "pn_c1_10", - ) - self.assertEqual( - closure.components.p4_port1_remote_port1_line.name, - "pneumatic_80", - ) - self.assertEqual(closure.components.p4_port1_far_node.alias, "pnnode4_19") - self.assertEqual( - closure.components.p4_port1_far_primary_line.name, - "pneumatic_65", - ) - self.assertEqual( - closure.components.p4_port1_far_primary_chamber.name, - "pn_c1_11", - ) - self.assertEqual(closure.components.p4_port1_far_port1_line.name, "pneumatic_85") - self.assertEqual(closure.components.p4_port1_next_node.alias, "pnnode4_23") - self.assertEqual( - closure.components.p4_port1_next_primary_line.name, - "pneumatic_71", - ) - self.assertEqual( - closure.components.p4_port1_next_primary_chamber.name, - "pn_c1_12", - ) - self.assertEqual(closure.components.p4_port1_next_port1_line.name, "pneumatic_81") - self.assertEqual(closure.components.p4_bridge_node.alias, "pnnode4_20") - self.assertEqual(closure.components.p4_bridge_primary_line.name, "pneumatic_72") - self.assertEqual(closure.components.p4_bridge_primary_chamber.name, "pn_c1_13") - self.assertEqual( - closure.components.p4_port1_remote_orifice.name, - "pn_morifice_10", - ) - self.assertEqual( - closure.components.p4_port1_remote_orifice_line.name, - "pneumatic_75", - ) - self.assertEqual( - closure.components.p4_port1_remote_orifice_node.alias, - "pn_node3_10", - ) - self.assertEqual(closure.components.p4_port3_line.name, "pneumatic_83") - self.assertEqual(closure.components.p4_port3_remote_node.alias, "pnnode4_16") - self.assertEqual( - closure.components.p4_port3_remote_primary_line.name, - "pneumatic_69", - ) - self.assertEqual( - closure.components.p4_port3_remote_primary_chamber.name, - "pn_c1_8", - ) - self.assertEqual( - closure.components.p4_port3_remote_port3_line.name, - "pneumatic_95", - ) - self.assertEqual(closure.components.p4_port3_far_node.alias, "pnnode4_22") - self.assertEqual( - closure.components.p4_port3_far_primary_line.name, - "pneumatic_74", - ) - self.assertEqual( - closure.components.p4_port3_far_primary_chamber.name, - "pn_c1_15", - ) - self.assertEqual(closure.components.p4_port3_far_port3_line.name, "pneumatic_82") - self.assertEqual(closure.components.p4_port3_next_node.alias, "pnnode4_21") - self.assertEqual( - closure.components.p4_port3_next_primary_line.name, - "pneumatic_73", - ) - self.assertEqual( - closure.components.p4_port3_next_primary_chamber.name, - "pn_c1_14", - ) - self.assertEqual(closure.components.p4_port3_next_port3_line.name, "pneumatic_84") - self.assertEqual( - closure.components.p4_port3_next_orifice.name, - "pn_morifice_13", - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_line.name, - "pneumatic_91", - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_node.alias, - "pn_node3_14", - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_output_line.name, - "pneumatic_93", - ) - self.assertEqual( - closure.components.p4_port3_next_chamber_inlet_line.name, - "pneumatic_103", - ) - self.assertEqual( - closure.components.p4_port3_next_chamber.name, - "pn_general_chamber_5", - ) - self.assertEqual( - closure.components.p4_port3_next_chamber_outlet_line.name, - "pneumatic_104", - ) - self.assertEqual( - closure.components.p4_port3_next_chamber_inlet_orifice.name, - "pn_orifice_24", - ) - self.assertEqual( - closure.components.p4_port3_next_chamber_outlet_orifice.name, - "pn_orifice_25", - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice.name, - "pn_morifice_1", - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice_line.name, - "pneumatic_87", - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice_node.alias, - "pn_node3_8", - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice_resistance.name, - "pneumatic_105", - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_node.alias, - "pn_node3_15", - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_node_line.name, - "pneumatic_92", - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_orifice.name, - "pn_morifice_14", - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_output_line.name, - "pneumatic_94", - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_resistance.name, - "pneumatic_106", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_node.alias, - "pn_node3_13", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_node_line.name, - "pneumatic_78", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_orifice.name, - "pn_morifice_12", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_output_line.name, - "pneumatic_70", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_inlet_line.name, - "pneumatic_101", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber.name, - "pn_general_chamber_4", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_outlet_line.name, - "pneumatic_102", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_inlet_orifice.name, - "pn_orifice_22", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_outlet_orifice.name, - "pn_orifice_23", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice.name, - "pn_morifice_11", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_line.name, - "pneumatic_77", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_node.alias, - "pn_node3_12", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_output_line.name, - "pneumatic_79", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance.name, - "pneumatic_107", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_node.alias, - "pn_node3_11", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_line.name, - "pneumatic_76", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_orifice.name, - "pn_morifice_15", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_inlet_line.name, - "pneumatic_98", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber.name, - "pn_general_chamber_2", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_outlet_line.name, - "pneumatic_99", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice.name, - "pn_orifice_20", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice.name, - "pn_orifice_21", - ) - self.assertEqual(len(closure.initial_state_vector()), 112) - self.assertEqual(len(rhs), 112) - self.assertAlmostEqual( - snapshot.p4_balance.pressure_pa, - snapshot.p4_primary_line.p, - ) - self.assertAlmostEqual( - snapshot.p4_balance.temperature_k, - snapshot.p4_primary_line.T, - ) - - def test_full_state_closure_combines_pneumatic_and_mechanical_states(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - - state = closure.initial_state_vector() - snapshot = closure.snapshot(state) - rhs = closure.rhs(state) - - self.assertEqual(closure.pneumatic_state_count, 112) - self.assertEqual(closure.mechanical_state_count, 20) - self.assertEqual(closure.state_count, 132) - self.assertEqual(len(state), 132) - self.assertEqual(snapshot.state_count, 132) - self.assertEqual(snapshot.mechanical.state_count, 20) - self.assertEqual(len(rhs), 132) - self.assertAlmostEqual( - snapshot.pneumatic.p4_balance.pressure_pa, - snapshot.pneumatic.p4_primary_line.p, - ) - self.assertEqual( - snapshot.mechanical.states[0].alias, - "mass_friction_endstops_10", - ) - self.assertAlmostEqual( - snapshot.mechanical_node_total_force_by_alias[ - "dynamic_mechanical_node_alternative_2" - ], - 0.0, - ) - self.assertAlmostEqual( - snapshot.mechanical_node_total_force_by_alias[ - "dynamic_mechanical_node_alternative_3" - ], - -326.7174678324367, - ) - self.assertAlmostEqual(rhs[112], -0.8167936695810917) - - def test_full_state_closure_feeds_piston_kinematics_to_variable_chambers(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - state = closure.initial_state_vector() - state[113] = 0.05 - - snapshot = closure.snapshot(state) - chamber = closure.pneumatic_closure.components.p4_port3_remote_primary_chamber - piston = closure.mechanical_closure.assembly.pistons["pn_brp2_8"] - expected_external_volume = piston.geometry().chamber_volume_m3(0.0, 0.05) - - self.assertAlmostEqual( - chamber.volume, - chamber.dead_volume + expected_external_volume, - ) - self.assertLess( - snapshot.pneumatic.p4_port3_remote_primary_chamber.p, - self.system.pneumatic_assembly.variable_initial_absolute_pressure_pa, - ) - - base_rhs = closure.rhs(closure.initial_state_vector()) - moving_state = closure.initial_state_vector() - moving_state[112] = 0.1 - moving_snapshot = closure.snapshot(moving_state) - rhs = closure.rhs(moving_state) - expected_volume_rate = piston.geometry().chamber_volume_rate_m3_s(0.0, 0.1) - - self.assertAlmostEqual(chamber.volume_rate_m3_s(), expected_volume_rate) - self.assertAlmostEqual( - rhs[47] - base_rhs[47], - -moving_snapshot.pneumatic.p4_port3_remote_primary_chamber.p - * expected_volume_rate, - ) - - def test_full_state_closure_applies_elastic_endstop_force_to_small_masses(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - state = closure.initial_state_vector() - state[113] = 1.0e-3 - - snapshot = closure.snapshot(state) - rhs = closure.rhs(state) - endstop = closure.mechanical_closure.assembly.elastic_endstops[ - "elasticendstop_8" - ].endstop() - expected_contact_force = endstop.contact_force(-1.0, 0.0) - mass = closure.mechanical_closure.assembly.masses[ - "mass_friction_endstops_10" - ].mass_kg - expected_piston_force = closure._piston_force_by_mass_alias( - snapshot.pneumatic - )["mass_friction_endstops_10"] - - self.assertAlmostEqual( - rhs[112], - (expected_piston_force - expected_contact_force) / mass, - ) - self.assertAlmostEqual( - snapshot.mechanical_node_total_force_by_alias[ - "dynamic_mechanical_node_alternative_2" - ], - expected_contact_force, - ) - - def test_full_state_closure_constrains_large_masses_at_active_limits(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertAlmostEqual(rhs[128], 0.0) - self.assertAlmostEqual(rhs[129], 0.0) - self.assertAlmostEqual(rhs[130], 0.0) - self.assertAlmostEqual(rhs[131], 0.0) - - def test_full_state_closure_releases_large_mass_when_node_force_points_inward(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - state = closure.initial_state_vector() - state[113] = 1.0e-3 - - rhs = closure.rhs_at(0.8000000000000009, state) - endstop = closure.mechanical_closure.assembly.elastic_endstops[ - "elasticendstop_8" - ].endstop() - expected_contact_force = endstop.contact_force(-1.0, 0.0) - support_mass = closure.mechanical_closure.assembly.masses[ - "mass_friction_endstops_18" - ].mass_kg - - self.assertAlmostEqual(rhs[128], expected_contact_force / support_mass) - self.assertAlmostEqual(rhs[129], 0.0) - - def test_full_state_closure_diagnoses_variable_chamber_rhs_terms(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - state = closure.initial_state_vector() - - diagnostic = closure.variable_chamber_rhs_diagnostic( - chamber_alias="pn_c1_8", - state_vector=state, - ) - rhs = closure.rhs(state) - - self.assertEqual(diagnostic.piston_alias, "pn_brp2_8") - self.assertAlmostEqual(diagnostic.chamber_volume_m3, 0.015) - self.assertAlmostEqual(diagnostic.chamber_volume_rate_m3_s, 0.0) - self.assertAlmostEqual(diagnostic.mass_derivative_kg_s, rhs[46]) - self.assertAlmostEqual(diagnostic.energy_derivative_w, rhs[47]) - self.assertAlmostEqual( - diagnostic.energy_derivative_w, - diagnostic.port_a_energy_flow_w - + diagnostic.port_b_energy_flow_w - + diagnostic.boundary_work_w - + diagnostic.thermal_energy_flow_w, - ) - - def test_full_state_closure_diagnoses_pnl0001_line_rhs_terms(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - state = closure.initial_state_vector() - - diagnostic = closure.pnl0001_line_rhs_diagnostic( - line_alias="pneumatic_69", - state_vector=state, - time_s=0.04, - ) - rhs = closure.rhs_at(0.04, state) - - self.assertEqual(diagnostic.chamber_alias, "pn_c1_8") - self.assertGreater(diagnostic.node_to_line_flow_kg_s, 0.0) - self.assertAlmostEqual( - diagnostic.mass_derivative_kg_s, - diagnostic.chamber_to_line_flow_kg_s - + diagnostic.node_to_line_flow_kg_s, - ) - self.assertAlmostEqual(diagnostic.mass_derivative_kg_s, rhs[44]) - self.assertAlmostEqual(diagnostic.energy_derivative_w, rhs[45]) - self.assertAlmostEqual( - diagnostic.energy_derivative_w, - diagnostic.port_1_energy_flow_w - + diagnostic.port_2_energy_flow_w - + diagnostic.thermal_energy_flow_w, - ) - - def test_full_state_closure_can_enable_pneumatic_96_reference_rhs_candidate( - self, - ) -> None: - default_closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - candidate_closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - use_pneumatic_96_reference_rhs=True, - ) - state = default_closure.initial_state_vector() - - default_rhs = default_closure.rhs_at(0.04, state) - candidate_rhs = candidate_closure.rhs_at(0.04, state) - snapshot = candidate_closure.snapshot_at(0.04, state).pneumatic - line = candidate_closure.pneumatic_closure.components.inlet_line - expected = candidate_closure.pneumatic_closure._pnl0001_reference_enthalpy_derivative( - line=line, - line_properties=snapshot.inlet_line, - port_1_properties=snapshot.p4_port3_remote_orifice_line_port_1, - port_1_flow=snapshot.inlet_node_to_line_flow, - port_2_properties=snapshot.chamber, - port_2_flow=-snapshot.inlet_line_to_chamber_flow, - ) - - self.assertAlmostEqual(candidate_rhs[0], default_rhs[0]) - self.assertAlmostEqual(candidate_rhs[0], expected.m) - self.assertAlmostEqual(candidate_rhs[1], expected.U) - self.assertNotAlmostEqual(candidate_rhs[1], default_rhs[1]) - - def test_full_state_closure_can_enable_pneumatic_69_reference_rhs_candidate( - self, - ) -> None: - default_closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - candidate_closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - use_pneumatic_69_reference_rhs=True, - ) - state = default_closure.initial_state_vector() - - default_rhs = default_closure.rhs_at(0.04, state) - candidate_rhs = candidate_closure.rhs_at(0.04, state) - snapshot = candidate_closure.snapshot_at(0.04, state).pneumatic - line = candidate_closure.pneumatic_closure.components.p4_port3_remote_primary_line - expected = candidate_closure.pneumatic_closure._pnl0001_reference_enthalpy_derivative( - line=line, - line_properties=snapshot.p4_port3_remote_primary_line, - port_1_properties=snapshot.p4_port3_remote_primary_chamber, - port_1_flow=snapshot.p4_port3_remote_chamber_to_line_flow, - port_2_properties=snapshot.p4_port3_remote_primary_line, - port_2_flow=snapshot.p4_port3_remote_node_to_primary_line_flow, - port_2_connected_reference_h=( - candidate_closure.pneumatic_closure._p4_port3_remote_port_2_reference_h( - snapshot=snapshot - ) - ), - ) - - self.assertAlmostEqual(candidate_rhs[0], default_rhs[0]) - self.assertAlmostEqual(candidate_rhs[1], default_rhs[1]) - self.assertAlmostEqual(candidate_rhs[44], default_rhs[44]) - self.assertAlmostEqual(candidate_rhs[44], expected.m) - self.assertAlmostEqual(candidate_rhs[45], expected.U) - self.assertNotAlmostEqual(candidate_rhs[45], default_rhs[45]) - - def test_full_state_closure_applies_step_controls_to_variable_orifices(self) -> None: - closure = self.system.full_state_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - state = closure.initial_state_vector() - - closed_snapshot = closure.snapshot_at(0.0, state).pneumatic - open_snapshot = closure.snapshot_at(0.04, state).pneumatic - - self.assertAlmostEqual( - closed_snapshot.p4_port3_remote_node_to_primary_line_flow, - 0.0, - ) - self.assertGreater( - open_snapshot.p4_port3_remote_node_to_primary_line_flow, - 0.0, - ) - node_inlet_h = ( - open_snapshot.p4_port3_remote_balance.port_2_enthalpy_flow_w - / open_snapshot.p4_port3_remote_node_to_primary_line_flow - ) - self.assertAlmostEqual( - node_inlet_h, - open_snapshot.p4_port3_remote_orifice_line_port_2.h, - ) - rhs = closure.rhs_at(0.04, state) - line = closure.pneumatic_closure.components.p4_port3_remote_primary_line - expected_line_derivative = ( - line.derivatives_from_transport_enthalpy_connections( - port_1_m_flow=( - open_snapshot.p4_port3_remote_chamber_to_line_flow - ), - connected_h_1=open_snapshot.p4_port3_remote_primary_chamber.h, - port_2_m_flow=( - open_snapshot.p4_port3_remote_node_to_primary_line_flow - ), - connected_h_2=node_inlet_h, - ) - ) - self.assertAlmostEqual(rhs[45], expected_line_derivative.U) - self.assertAlmostEqual( - self.system.pneumatic_assembly.variable_orifices[ - "pn_morifice_1" - ].opening, - 1.0, - ) - remote_orifice_line = ( - closure.pneumatic_closure.components.p4_port3_remote_orifice_line - ) - custom_port_1_h = open_snapshot.p4_port3_remote_orifice_line_port_1.h + 1234.0 - custom_d1, _ = closure.pneumatic_closure._two_state_connection_line_derivatives( - line=remote_orifice_line, - port_1_properties=open_snapshot.p4_port3_remote_orifice_line_port_1, - port_1_flow=0.01, - port_2_connected_properties=open_snapshot.p4_port3_remote_primary_line, - port_2_flow=-open_snapshot.p4_port3_remote_orifice_to_node_flow, - port_1_connected_h=custom_port_1_h, - ) - expected_custom_d1, _ = remote_orifice_line.derivatives_from_connections( - port_1_m_flow=0.01, - connected_h_1=custom_port_1_h, - port_2_m_flow=-open_snapshot.p4_port3_remote_orifice_to_node_flow, - connected_h_2=open_snapshot.p4_port3_remote_primary_line.h, - ) - default_d1, _ = closure.pneumatic_closure._two_state_connection_line_derivatives( - line=remote_orifice_line, - port_1_properties=open_snapshot.p4_port3_remote_orifice_line_port_1, - port_1_flow=0.01, - port_2_connected_properties=open_snapshot.p4_port3_remote_primary_line, - port_2_flow=-open_snapshot.p4_port3_remote_orifice_to_node_flow, - ) - self.assertAlmostEqual(custom_d1.U, expected_custom_d1.U) - self.assertNotAlmostEqual(custom_d1.U, default_d1.U) - closure.snapshot_at(0.0, state) - self.assertAlmostEqual( - self.system.pneumatic_assembly.variable_orifices[ - "pn_morifice_1" - ].opening, - 0.0, - ) - - def test_simulates_full_state_key_data_paths_for_amesim_comparison(self) -> None: - data_paths = ( - "press@pn_c1_8", - "vol@pn_c1_8", - "mgas1@pn_c1_8", - "vol1@pn_brp2_8", - "vvol1@pn_brp2_8", - "x1@mass_friction_endstops_10", - "v1@mass_friction_endstops_10", - "acc1@mass_friction_endstops_10", - "x1@mass_friction_endstops_18", - "v1@mass_friction_endstops_18", - "acc1@mass_friction_endstops_18", - "p2@pneumatic_69", - "t2@pneumatic_69", - "mgas@pneumatic_69", - "re@pneumatic_69", - "v@pneumatic_69", - "ff@pneumatic_69", - "dm1@pneumatic_69", - "xv@pn_morifice_1", - "dm2@pn_morifice_1", - ) - - result = self.system.simulate_full_state_series_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - config=SolveIVPConfig(t_start=0.0, t_stop=0.0), - data_paths=data_paths, - ) - comparison = compare_validated_test_mql_output( - times=result.t, - series_by_data_path={data_path: result.series[data_path] for data_path in data_paths}, - schema=self.output_schema, - amesim_results=self.amesim_results, - data_paths=data_paths, - ) - - self.assertEqual(result.series["time"], [0.0]) - self.assertEqual(len(result.y), 132) - self.assertEqual(len(comparison.metrics), len(data_paths)) - self.assertAlmostEqual(result.series["press@pn_c1_8"][0], -1300.0) - self.assertAlmostEqual(result.series["vol@pn_c1_8"][0], 15000.0) - self.assertAlmostEqual( - result.series["mgas1@pn_c1_8"][0], - self.amesim_results.series("mgas1@pn_c1_8")[0], - delta=1.0e-4, - ) - self.assertAlmostEqual(result.series["vol1@pn_brp2_8"][0], 0.0) - self.assertAlmostEqual(result.series["vvol1@pn_brp2_8"][0], 0.0) - self.assertAlmostEqual(result.series["x1@mass_friction_endstops_10"][0], 0.0) - self.assertAlmostEqual(result.series["v1@mass_friction_endstops_10"][0], 0.0) - self.assertAlmostEqual( - result.series["acc1@mass_friction_endstops_10"][0], - -0.8167936695810917, - ) - self.assertAlmostEqual(result.series["acc1@mass_friction_endstops_18"][0], 0.0) - self.assertAlmostEqual(result.series["p2@pneumatic_69"][0], -1300.0) - self.assertAlmostEqual(result.series["t2@pneumatic_69"][0], 293.15) - self.assertAlmostEqual( - result.series["mgas@pneumatic_69"][0], - self.amesim_results.series("mgas@pneumatic_69")[0], - delta=1.0e-5, - ) - self.assertAlmostEqual(result.series["re@pneumatic_69"][0], 0.0) - self.assertAlmostEqual(result.series["v@pneumatic_69"][0], 0.0) - self.assertGreater(result.series["ff@pneumatic_69"][0], 0.0) - self.assertAlmostEqual(result.series["dm1@pneumatic_69"][0], 0.0) - self.assertAlmostEqual(result.series["xv@pn_morifice_1"][0], 0.0) - self.assertAlmostEqual(result.series["dm2@pn_morifice_1"][0], 0.0) - - def test_simulates_full_132_state_closure(self) -> None: - solution = self.system.simulate_full_state_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - config=SolveIVPConfig(t_start=0.0, t_stop=1.0e-5, max_step=1.0e-6), - t_eval=[0.0, 1.0e-5], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(solution.t), 2) - self.assertEqual(len(solution.y), 132) - self.assertEqual([len(row) for row in solution.y], [2] * 132) - - def test_p4_closure_uses_structured_neighborhood(self) -> None: - closure = self.system.pn3_p4_node_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - neighborhood = self.system.p4_node_neighborhood(closure.components.p4_node.alias) - - self.assertEqual( - closure.components.p4_primary_line.name, - neighborhood.primary.line_alias, - ) - self.assertEqual( - closure.components.p4_primary_chamber.name, - neighborhood.primary.chamber_alias, - ) - self.assertEqual( - closure.components.p4_port1_line.name, - neighborhood.port_1.line_alias, - ) - self.assertEqual( - closure.components.p4_port1_remote_node.alias, - neighborhood.port_1.remote_node_alias, - ) - port1_remote_neighborhood = self.system.p4_node_neighborhood( - neighborhood.port_1.remote_node_alias - ) - self.assertEqual( - closure.components.p4_port1_remote_port1_line.name, - port1_remote_neighborhood.port_1.line_alias, - ) - self.assertEqual( - closure.components.p4_port1_far_node.alias, - port1_remote_neighborhood.port_1.remote_node_alias, - ) - self.assertEqual( - closure.components.p4_port1_far_primary_line.name, - self.system._p4_primary_connection_for_node( - port1_remote_neighborhood.port_1.remote_node_alias - ).line_alias, - ) - port1_far_port1 = self.system._pnl0002_connection_for_node_port( - closure.components.p4_port1_far_node.alias, - "port_1", - ) - self.assertEqual( - closure.components.p4_port1_far_port1_line.name, - port1_far_port1.line_alias, - ) - self.assertEqual( - closure.components.p4_port1_next_node.alias, - port1_far_port1.remote_node_alias, - ) - self.assertEqual( - closure.components.p4_port1_next_primary_line.name, - self.system._p4_primary_connection_for_node( - port1_far_port1.remote_node_alias - ).line_alias, - ) - port1_next_port1 = self.system._pnl0002_connection_for_node_port( - closure.components.p4_port1_next_node.alias, - "port_1", - ) - self.assertEqual( - closure.components.p4_port1_next_port1_line.name, - port1_next_port1.line_alias, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_output_line.name, - self.system._pnl0001_line_for_node_port( - closure.components.p4_port1_next_node.alias, - "port_4", - ).name, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_node.alias, - "pn_node3_12", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_line.name, - self.system._pnl0003_line_for_node_port("pn_node3_12", "port_2").name, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice.name, - "pn_morifice_11", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance.name, - self.system._pnl00r_line_for_node_port("pn_node3_12", "port_3").name, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_node.alias, - "pn_node3_11", - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_line.name, - self.system._pnl0003_line_for_node_port("pn_node3_11", "port_2").name, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_orifice.name, - "pn_morifice_15", - ) - resistance_chamber_segment = ( - self.system.discover_pneumatic_branch_topology().chamber_segment_specs[1] - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_spec, - resistance_chamber_segment, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_inlet_line.name, - resistance_chamber_segment.inlet_line_alias, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_outlet_line.name, - resistance_chamber_segment.outlet_line_alias, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_inlet_orifice.name, - resistance_chamber_segment.inlet_orifice_alias, - ) - self.assertEqual( - closure.components.p4_port1_next_orifice_resistance_chamber_outlet_orifice.name, - resistance_chamber_segment.outlet_orifice_alias, - ) - self.assertEqual( - closure.components.p4_bridge_node.alias, - port1_next_port1.remote_node_alias, - ) - self.assertEqual( - closure.components.p4_bridge_primary_line.name, - self.system._p4_primary_connection_for_node( - closure.components.p4_bridge_node.alias - ).line_alias, - ) - self.assertEqual( - closure.components.p4_port1_remote_orifice.name, - port1_remote_neighborhood.port_4.orifice_alias, - ) - self.assertEqual( - closure.components.p4_port1_remote_orifice_line.name, - self.system._pnl0003_line_for_node_port("pn_node3_10", "port_2").name, - ) - self.assertEqual( - closure.components.p4_port1_remote_orifice_node.alias, - "pn_node3_10", - ) - self.assertEqual( - closure.components.node_resistance.name, - self.system._pnl00r_line_for_node_port("pn_node3_10", "port_3").name, - ) - self.assertEqual( - closure.components.node.alias, - "pn_node3_9", - ) - self.assertEqual( - closure.components.p4_port3_line.name, - neighborhood.port_3.line_alias, - ) - self.assertEqual( - closure.components.p4_port3_remote_node.alias, - neighborhood.port_3.remote_node_alias, - ) - port3_remote_neighborhood = self.system.p4_node_neighborhood( - neighborhood.port_3.remote_node_alias - ) - self.assertEqual( - closure.components.p4_port3_remote_port3_line.name, - port3_remote_neighborhood.port_3.line_alias, - ) - self.assertEqual( - closure.components.p4_port3_far_node.alias, - port3_remote_neighborhood.port_3.remote_node_alias, - ) - self.assertEqual( - closure.components.p4_port3_far_primary_line.name, - self.system._p4_primary_connection_for_node( - port3_remote_neighborhood.port_3.remote_node_alias - ).line_alias, - ) - port3_far_port3 = self.system._pnl0002_connection_for_node_port( - closure.components.p4_port3_far_node.alias, - "port_3", - ) - self.assertEqual( - closure.components.p4_port3_far_port3_line.name, - port3_far_port3.line_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_node.alias, - port3_far_port3.remote_node_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_primary_line.name, - self.system._p4_primary_connection_for_node( - port3_far_port3.remote_node_alias - ).line_alias, - ) - port3_next_port3 = self.system._pnl0002_connection_for_node_port( - closure.components.p4_port3_next_node.alias, - "port_3", - ) - self.assertEqual( - closure.components.p4_port3_next_port3_line.name, - port3_next_port3.line_alias, - ) - self.assertEqual( - closure.components.p4_bridge_node.alias, - port3_next_port3.remote_node_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_output_line.name, - self.system._pnl0001_line_for_node_port( - closure.components.p4_port3_next_node.alias, - "port_4", - ).name, - ) - self.assertEqual( - closure.components.p4_port3_next_orifice.name, - "pn_morifice_13", - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_line.name, - self.system._pnl0003_line_for_node_port("pn_node3_14", "port_2").name, - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_node.alias, - "pn_node3_14", - ) - chamber_segment = ( - self.system.discover_pneumatic_branch_topology().chamber_segment_specs[3] - ) - self.assertEqual( - closure.components.p4_port3_next_chamber_spec, - chamber_segment, - ) - self.assertEqual( - closure.components.p4_port3_next_chamber_inlet_line.name, - chamber_segment.inlet_line_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_chamber_outlet_line.name, - chamber_segment.outlet_line_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_orifice_resistance.name, - self.system._pnl00r_line_for_node_port("pn_node3_14", "port_3").name, - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_node.alias, - "pn_node3_13", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_node_line.name, - self.system._pnl0003_line_for_node_port("pn_node3_13", "port_2").name, - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_orifice.name, - "pn_morifice_12", - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_output_line.name, - self.system._pnl0001_line_for_node_port( - closure.components.p4_bridge_node.alias, - "port_4", - ).name, - ) - resistance_chamber_segment = ( - self.system.discover_pneumatic_branch_topology().chamber_segment_specs[2] - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_spec, - resistance_chamber_segment, - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_inlet_line.name, - resistance_chamber_segment.inlet_line_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_outlet_line.name, - resistance_chamber_segment.outlet_line_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_inlet_orifice.name, - resistance_chamber_segment.inlet_orifice_alias, - ) - self.assertEqual( - closure.components.p4_port3_next_resistance_chamber_outlet_orifice.name, - resistance_chamber_segment.outlet_orifice_alias, - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice.name, - port3_remote_neighborhood.port_4.orifice_alias, - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice_line.name, - self.system._pnl0003_line_for_node_port("pn_node3_8", "port_2").name, - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice_node.alias, - "pn_node3_8", - ) - self.assertEqual( - closure.components.p4_port3_remote_orifice_resistance.name, - self.system._pnl00r_line_for_node_port("pn_node3_8", "port_3").name, - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_node.alias, - "pn_node3_15", - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_node_line.name, - self.system._pnl0003_line_for_node_port("pn_node3_15", "port_2").name, - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_orifice.name, - "pn_morifice_14", - ) - self.assertEqual( - closure.components.p4_port3_remote_resistance_output_line.name, - self.system._pnl0001_line_for_node_port( - closure.components.p4_port3_far_node.alias, - "port_4", - ).name, - ) - self.assertEqual( - closure.components.node_orifice.name, - neighborhood.port_4.orifice_alias, - ) - - def test_p4_node_flow_cancels_between_pnvo_pnl0002_and_primary_line(self) -> None: - closure = self.system.pn3_p4_node_chamber_segment_closure_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - ) - - self.assertAlmostEqual(closure.components.node_orifice.opening, 0.0) - closure.components.node_orifice.opening = 1.0 - snapshot = closure.snapshot() - rhs = closure.rhs(closure.initial_state_vector()) - - self.assertGreater(snapshot.pnl0003_to_p4_flow, 0.0) - self.assertAlmostEqual( - snapshot.p4_balance.port_2_mass_flow_g_s, - ( - -snapshot.p4_to_port1_line_flow - - snapshot.p4_to_port3_line_flow - + snapshot.pnl0003_to_p4_flow - ) - * 1.0e3, - ) - self.assertAlmostEqual( - snapshot.p4_node_to_primary_line_flow, - snapshot.p4_balance.port_2_mass_flow_g_s * 1.0e-3, - ) - self.assertAlmostEqual( - snapshot.p4_node_to_primary_line_flow - + snapshot.p4_to_port1_line_flow - + snapshot.p4_to_port3_line_flow - - snapshot.pnl0003_to_p4_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port1_remote_node_to_primary_line_flow - + snapshot.p4_port1_remote_node_to_line_flow - + snapshot.p4_port1_remote_to_port1_line_flow - - snapshot.p4_port1_remote_orifice_to_node_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port1_far_node_to_primary_line_flow - + snapshot.p4_port1_far_node_to_line_flow - + snapshot.p4_port1_far_to_next_line_flow - - snapshot.p4_port1_next_orifice_resistance_to_p4_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port1_next_node_to_primary_line_flow - + snapshot.p4_port1_next_node_to_line_flow - + snapshot.p4_port1_next_to_bridge_line_flow - + snapshot.p4_port1_next_orifice_p4_to_output_line_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_bridge_node_to_primary_line_flow - + snapshot.p4_bridge_to_port1_next_line_flow - + snapshot.p4_bridge_to_port3_next_line_flow - - snapshot.p4_port3_next_resistance_output_to_p4_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port1_remote_node_to_orifice_line_flow - + snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow - - snapshot.node_to_resistance_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.node_to_pnl0003_flow - + snapshot.node_to_resistance_flow - + snapshot.outlet_node_to_line_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_remote_node_to_orifice_line_flow - + snapshot.inlet_node_to_line_flow - + snapshot.p4_port3_remote_node_to_resistance_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_remote_node_to_primary_line_flow - + snapshot.p4_port3_remote_node_to_line_flow - + snapshot.p4_port3_remote_to_port3_line_flow - - snapshot.p4_port3_remote_orifice_to_node_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_remote_resistance_node_to_line_flow - - snapshot.p4_port3_remote_node_to_resistance_flow - + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_remote_resistance_output_to_p4_flow, - snapshot.p4_port3_remote_resistance_to_output_line_flow, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_far_node_to_primary_line_flow - + snapshot.p4_port3_far_node_to_line_flow - + snapshot.p4_port3_far_to_next_line_flow - - snapshot.p4_port3_remote_resistance_output_to_p4_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_next_node_to_primary_line_flow - + snapshot.p4_port3_next_node_to_line_flow - + snapshot.p4_port3_next_to_bridge_line_flow - - snapshot.p4_port3_next_orifice_output_to_p4_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_next_orifice_node_to_line_flow - + snapshot.p4_port3_next_chamber_inlet_node_to_line_flow - - snapshot.p4_port3_next_resistance_node_to_next_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_next_orifice_output_to_p4_flow, - snapshot.p4_port3_next_orifice_to_output_line_flow, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_next_resistance_node_to_line_flow - + snapshot.p4_port3_next_resistance_node_to_next_flow - + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port3_next_resistance_output_to_p4_flow, - snapshot.p4_port3_next_resistance_to_output_line_flow, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port1_next_orifice_node_to_line_flow - + snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow - - snapshot.p4_port1_next_orifice_resistance_node_to_next_flow, - 0.0, - delta=1.0e-12, - ) - self.assertAlmostEqual( - snapshot.p4_port1_next_orifice_resistance_node_to_line_flow - + snapshot.p4_port1_next_orifice_resistance_node_to_next_flow - + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow, - 0.0, - delta=1.0e-12, - ) - next_chamber_mass_sum = rhs[78] + rhs[80] + rhs[82] - self.assertAlmostEqual( - next_chamber_mass_sum, - ( - snapshot.p4_port3_next_chamber_inlet_node_to_line_flow - + snapshot.p4_port3_next_chamber_outlet_node_to_line_flow - ), - delta=1.0e-12, - ) - resistance_chamber_mass_sum = rhs[90] + rhs[92] + rhs[94] - self.assertAlmostEqual( - resistance_chamber_mass_sum, - ( - snapshot.p4_port3_next_resistance_chamber_inlet_node_to_line_flow - + snapshot.p4_port3_next_resistance_chamber_outlet_node_to_line_flow - ), - delta=1.0e-12, - ) - p4_port1_next_orifice_line_mass_sum = rhs[96] + rhs[98] - self.assertAlmostEqual( - p4_port1_next_orifice_line_mass_sum, - ( - snapshot.p4_port1_next_orifice_node_to_line_flow - - snapshot.p4_port1_next_orifice_to_output_line_flow - ), - delta=1.0e-12, - ) - self.assertAlmostEqual( - rhs[100], - ( - snapshot.p4_port1_next_orifice_p4_to_output_line_flow - + snapshot.p4_port1_next_orifice_to_output_line_flow - ), - delta=1.0e-12, - ) - p4_port1_next_orifice_resistance_line_mass_sum = rhs[102] + rhs[104] - self.assertAlmostEqual( - p4_port1_next_orifice_resistance_line_mass_sum, - ( - snapshot.p4_port1_next_orifice_resistance_node_to_line_flow - - snapshot.p4_port1_next_orifice_resistance_to_p4_flow - ), - delta=1.0e-12, - ) - p4_port1_next_orifice_resistance_chamber_mass_sum = ( - rhs[106] + rhs[108] + rhs[110] - ) - self.assertAlmostEqual( - p4_port1_next_orifice_resistance_chamber_mass_sum, - ( - snapshot.p4_port1_next_orifice_resistance_chamber_inlet_node_to_line_flow - + snapshot.p4_port1_next_orifice_resistance_chamber_outlet_node_to_line_flow - ), - delta=1.0e-12, - ) - mass_derivative_sum = sum(rhs[index] for index in range(0, 112, 2)) - self.assertAlmostEqual( - mass_derivative_sum, - 0.0, - delta=1.0e-12, - ) - - def test_simulates_one_hundred_twelve_state_pn3_p4_node_chamber_segment(self) -> None: - solution = self.system.simulate_pn3_p4_node_chamber_segment_from_spec( - self.spec, - inlet_node_pressure_pa=15.31e6, - resistance_boundary_pressure_pa=15.29e6, - config=SolveIVPConfig( - t_start=0.0, - t_stop=1.0e-10, - max_step=1.0e-11, - ), - t_eval=[0.0, 5.0e-11, 1.0e-10], - ) - - self.assertTrue(solution.success) - self.assertEqual(len(solution.t), 3) - self.assertEqual(len(solution.y), 112) - self.assertEqual([len(row) for row in solution.y], [3] * 112) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_pnl0002.py b/tests/test_test_mql_pnl0002.py deleted file mode 100644 index 24eab0a..0000000 --- a/tests/test_test_mql_pnl0002.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.examples.test_mql.system import TestMqlSystem -from app.simulation.examples.test_mql.line_parameters import load_test_mql_pnl0002_specs -from app.simulation.examples.test_mql.pneumatic_lines import build_test_mql_pnl0002_assembly - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlPnl0002Tests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.pnl0002_specs = load_test_mql_pnl0002_specs(TEST_MQL_AME) - cls.pnl0002_assembly = build_test_mql_pnl0002_assembly(TEST_MQL_AME) - cls.results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_loads_all_real_pnl0002_parameters_from_cir(self) -> None: - self.assertEqual(len(self.pnl0002_specs), 8) - spec = self.pnl0002_assembly.spec("pneumatic_86") - - self.assertEqual(spec.source_component, "pnnode4_17") - self.assertEqual(spec.source_port, "port_1") - self.assertEqual(spec.target_component, "pnnode4_18") - self.assertEqual(spec.target_port, "port_3") - self.assertEqual(spec.diameter_mm, 20.0) - self.assertEqual(spec.length_m, 2.0) - self.assertAlmostEqual(spec.relative_roughness, 0.045 / 20.0) - self.assertEqual(spec.gas_type_index, 1) - self.assertEqual(spec.mode, 2) - self.assertAlmostEqual(spec.initial_center_temperature_k, 293.15) - self.assertAlmostEqual(spec.initial_center_gauge_pressure_pa, -1300.0) - self.assertAlmostEqual(spec.initial_center_absolute_pressure_pa, 100_000.0) - - def test_builds_pnl0002_center_compliance_line_components(self) -> None: - self.assertEqual(len(self.pnl0002_assembly.lines), 8) - self.assertTrue( - all(len(line.get_state_vector()) == 2 for line in self.pnl0002_assembly.lines.values()) - ) - - def test_pneumatic_86_initial_observables_match_amesim_baseline(self) -> None: - pipe = self.pnl0002_assembly.lines["pneumatic_86"] - properties = pipe.properties() - - self.assertAlmostEqual( - properties.p - 101_300.0, - self.results.series("pctr@pneumatic_86")[0], - delta=1.0e-6, - ) - self.assertAlmostEqual( - properties.T, - self.results.series("tctr@pneumatic_86")[0], - delta=1.0e-12, - ) - self.assertAlmostEqual( - pipe.gas_mass_g(), - self.results.series("mgas@pneumatic_86")[0], - delta=0.001, - ) - - def test_amesim_mass_balance_uses_opposite_saved_port_flow_signs(self) -> None: - index = 500 - times = self.results.times - mass = self.results.series("mgas@pneumatic_86") - finite_difference_g_s = ( - mass[index + 1] - mass[index - 1] - ) / (times[index + 1] - times[index - 1]) - saved_port_flow_g_s = ( - self.results.series("dm1@pneumatic_86")[index] - + self.results.series("dm2@pneumatic_86")[index] - ) - - self.assertAlmostEqual( - finite_difference_g_s, - -saved_port_flow_g_s, - delta=1.0e-5, - ) - - def test_system_exposes_real_pnl0002_assembly(self) -> None: - system = TestMqlSystem() - - self.assertEqual(system.typed_pnl0002_line_count, 8) - self.assertIn("pneumatic_86", system.pnl0002_assembly.lines) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_pnl0003_pnl00r.py b/tests/test_test_mql_pnl0003_pnl00r.py deleted file mode 100644 index 5560b9a..0000000 --- a/tests/test_test_mql_pnl0003_pnl00r.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.examples.test_mql.system import TestMqlSystem -from app.simulation.examples.test_mql.line_parameters import ( - load_test_mql_pnl0003_specs, - load_test_mql_pnl00r_specs, -) -from app.simulation.examples.test_mql.pneumatic_lines import ( - build_test_mql_pnl0003_assembly, - build_test_mql_pnl00r_assembly, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlPnl0003AndPnl00rTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.pnl0003_specs = load_test_mql_pnl0003_specs(TEST_MQL_AME) - cls.pnl00r_specs = load_test_mql_pnl00r_specs(TEST_MQL_AME) - cls.pnl0003_assembly = build_test_mql_pnl0003_assembly(TEST_MQL_AME) - cls.pnl00r_assembly = build_test_mql_pnl00r_assembly(TEST_MQL_AME) - cls.results = load_test_mql_amesim_results(TEST_MQL_AME) - - def test_loads_all_real_pnl0003_parameters_from_cir(self) -> None: - self.assertEqual(len(self.pnl0003_specs), 8) - spec = self.pnl0003_assembly.spec("pneumatic_88") - - self.assertEqual(spec.source_component, "pn_node3_9") - self.assertEqual(spec.target_component, "pn_morifice_9") - self.assertEqual(spec.diameter_mm, 20.0) - self.assertEqual(spec.length_m, 0.3) - self.assertAlmostEqual(spec.relative_roughness, 0.045 / 20.0) - self.assertEqual(spec.gas_type_index, 1) - self.assertEqual(spec.mode, 2) - self.assertAlmostEqual(spec.initial_gauge_pressure_1_pa, 15_198_700.0) - self.assertAlmostEqual(spec.initial_gauge_pressure_2_pa, 15_198_700.0) - self.assertAlmostEqual(spec.initial_absolute_pressure_1_pa, 15_300_000.0) - self.assertAlmostEqual(spec.initial_absolute_pressure_2_pa, 15_300_000.0) - - def test_loads_all_real_pnl00r_parameters_from_cir(self) -> None: - self.assertEqual(len(self.pnl00r_specs), 4) - spec = self.pnl00r_assembly.spec("pneumatic_100") - - self.assertEqual(spec.source_component, "pn_node3_9") - self.assertEqual(spec.target_component, "pn_node3_10") - self.assertEqual(spec.diameter_mm, 14.0) - self.assertEqual(spec.length_m, 1.0) - self.assertAlmostEqual(spec.relative_roughness, 0.045 / 14.0) - self.assertEqual(spec.gas_type_index, 1) - - def test_builds_pnl0003_and_pnl00r_physical_line_components(self) -> None: - self.assertEqual(len(self.pnl0003_assembly.lines), 8) - self.assertEqual(len(self.pnl00r_assembly.lines), 4) - self.assertTrue( - all(len(line.get_state_vector()) == 4 for line in self.pnl0003_assembly.lines.values()) - ) - - def test_pneumatic_88_initial_observables_match_amesim_baseline(self) -> None: - pipe = self.pnl0003_assembly.lines["pneumatic_88"] - - self.assertAlmostEqual( - pipe.properties_1().p - 101_300.0, - self.results.series("p1@pneumatic_88")[0], - delta=1.0e-5, - ) - self.assertAlmostEqual( - pipe.properties_2().p - 101_300.0, - self.results.series("p2@pneumatic_88")[0], - delta=1.0e-5, - ) - self.assertAlmostEqual( - pipe.gas_mass_g(), - self.results.series("mgas@pneumatic_88")[0], - delta=0.005, - ) - - def test_system_exposes_real_pnl0003_and_pnl00r_assemblies(self) -> None: - system = TestMqlSystem() - - self.assertEqual(system.typed_pnl0003_line_count, 8) - self.assertEqual(system.typed_pnl00r_line_count, 4) - self.assertIn("pneumatic_88", system.pnl0003_assembly.lines) - self.assertIn("pneumatic_100", system.pnl00r_assembly.lines) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_structural.py b/tests/test_test_mql_structural.py deleted file mode 100644 index b8568c8..0000000 --- a/tests/test_test_mql_structural.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -import unittest - -from app.simulation.examples.test_mql.primitives.pneumatic import ( - AmesimPneumaticOrifice, - AmesimPneumaticVolume, - AmesimVariablePneumaticVolume, -) -from app.simulation.examples.test_mql.system import GLOBAL_PARAMETERS, SUBMODEL_COUNTS, TestMqlSystem - - -class TestMqlStructuralTest(unittest.TestCase): - def test_snapshot_matches_amesim_archive_metadata(self) -> None: - snapshot = TestMqlSystem().snapshot() - - self.assertEqual(snapshot.model_name, "test_mql") - self.assertEqual(snapshot.component_count, 117) - self.assertEqual(snapshot.connection_count, 84) - self.assertEqual(snapshot.continuous_state_count, 132) - self.assertEqual(snapshot.discrete_state_count, 24) - self.assertEqual(snapshot.global_parameters["P0"], "153") - self.assertEqual(snapshot.global_parameters["cf"], "0.45") - - def test_network_preserves_component_and_connection_counts(self) -> None: - system = TestMqlSystem() - - self.assertEqual(len(system.network.components), 117) - self.assertEqual(len(system.network.connections), 84) - self.assertIn("pn_brp2_8", system.network.components) - self.assertIn("pn_gas_data", system.network.components) - - def test_network_uses_typed_pneumatic_components_where_available(self) -> None: - system = TestMqlSystem() - - self.assertEqual(system.typed_pneumatic_component_count, 28) - self.assertIsInstance( - system.network.components["pn_general_chamber"], - AmesimPneumaticVolume, - ) - self.assertIsInstance( - system.network.components["pn_c1_8"], - AmesimVariablePneumaticVolume, - ) - self.assertIsInstance( - system.network.components["pn_orifice_18"], - AmesimPneumaticOrifice, - ) - self.assertIsInstance( - system.network.components["pn_morifice_11"], - AmesimPneumaticOrifice, - ) - self.assertEqual(len(system.pneumatic_state_vector()), 24) - - def test_can_build_pneumatic_branch_closure_from_typed_components(self) -> None: - system = TestMqlSystem() - - closure = system.pneumatic_branch_closure( - name="typed_branch", - upstream_volume_alias="pn_general_chamber", - orifice_alias="pn_orifice_18", - downstream_volume_alias="pn_c1_8", - ) - snapshot = closure.snapshot() - - self.assertGreater(snapshot.flow, 0.0) - self.assertIs( - closure.components.upstream_volume, - system.network.components["pn_general_chamber"], - ) - self.assertIs( - closure.components.orifice, - system.network.components["pn_orifice_18"], - ) - self.assertIs( - closure.components.downstream_volume, - system.network.components["pn_c1_8"], - ) - self.assertAlmostEqual( - closure.components.upstream_volume.port_a.m_flow, - -snapshot.flow, - ) - self.assertAlmostEqual( - closure.components.downstream_volume.port_a.m_flow, - snapshot.flow, - ) - - def test_pneumatic_branch_closure_rejects_structural_components(self) -> None: - system = TestMqlSystem() - - with self.assertRaises(TypeError): - system.pneumatic_branch_closure( - name="invalid_branch", - upstream_volume_alias="pn_brp2_8", - orifice_alias="pn_orifice_18", - downstream_volume_alias="pn_c1_8", - ) - - def test_submodel_counts_keep_key_amesim_families(self) -> None: - self.assertEqual(SUBMODEL_COUNTS["PNRP17"], 8) - self.assertEqual(SUBMODEL_COUNTS["PNCH012"], 8) - self.assertEqual(SUBMODEL_COUNTS["PNOR001"], 8) - self.assertEqual(SUBMODEL_COUNTS["PNVO001"], 8) - self.assertEqual(SUBMODEL_COUNTS["PNGD00"], 1) - self.assertEqual(GLOBAL_PARAMETERS["V"], "15") - - def test_structural_simulation_shape(self) -> None: - result = TestMqlSystem().simulate() - - self.assertEqual(len(result.t), 101) - self.assertEqual(len(result.y), 132) - self.assertEqual(result.series["component_count"][0], 117.0) - self.assertEqual(result.series["connection_count"][0], 84.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_topology.py b/tests/test_test_mql_topology.py deleted file mode 100644 index afcdb5a..0000000 --- a/tests/test_test_mql_topology.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.examples.test_mql.topology import load_test_mql_cir_topology - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlCirTopologyTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.topology = load_test_mql_cir_topology(TEST_MQL_AME) - - def test_recovers_direct_component_contacts_omitted_from_line_specs(self) -> None: - self.assertEqual(len(self.topology.component_contacts), 54) - contacts = self.topology.contacts_for("pn_general_chamber") - - self.assertEqual(len(contacts), 2) - self.assertEqual( - { - ( - contact.port_for("pn_general_chamber"), - *contact.other_endpoint("pn_general_chamber"), - ) - for contact in contacts - }, - { - ("port_1", "pn_orifice_19", "port_2"), - ("port_2", "pn_orifice_18", "port_1"), - }, - ) - - def test_component_contact_lookup_rejects_unrelated_alias(self) -> None: - contact = self.topology.contacts_for("pn_general_chamber")[0] - - with self.assertRaises(KeyError): - contact.other_endpoint("not-an-endpoint") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_test_mql_variables.py b/tests/test_test_mql_variables.py deleted file mode 100644 index d9ba682..0000000 --- a/tests/test_test_mql_variables.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -from app.simulation.reporting.amesim_results import load_test_mql_amesim_results -from app.simulation.reporting.test_mql_variables import ( - build_test_mql_variable_catalog, - split_data_path, -) - - -REPO_ROOT = Path(__file__).resolve().parents[1] -TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame" - - -class TestMqlVariableCatalogTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.amesim_results = load_test_mql_amesim_results(TEST_MQL_AME) - cls.catalog = build_test_mql_variable_catalog(cls.amesim_results) - - def test_catalog_covers_all_amesim_data_paths(self) -> None: - self.assertEqual(self.catalog.data_path_count, 1100) - self.assertEqual(self.catalog.saved_data_path_count, 1100) - self.assertEqual(self.catalog.counts_by_owner_kind(), {"component": 712, "connection": 388}) - - def test_component_variable_mapping_preserves_alias_and_submodel(self) -> None: - variable = self.catalog.by_data_path("temp3@pn_c1_8") - - self.assertEqual(variable.index, 0) - self.assertEqual(variable.signal_name, "temp3") - self.assertEqual(variable.owner_alias, "pn_c1_8") - self.assertEqual(variable.owner_kind, "component") - self.assertEqual(variable.submodel, "PNCH012") - self.assertEqual(variable.units, "K") - self.assertTrue(variable.saved) - - def test_connection_variable_mapping_preserves_line_submodel(self) -> None: - variable = self.catalog.by_data_path("dm1@pneumatic_69") - - self.assertEqual(variable.signal_name, "dm1") - self.assertEqual(variable.owner_alias, "pneumatic_69") - self.assertEqual(variable.owner_kind, "connection") - self.assertEqual(variable.submodel, "PNL0001") - self.assertEqual(variable.units, "g/s") - - def test_counts_by_submodel_include_components_and_connections(self) -> None: - counts = self.catalog.counts_by_submodel() - - self.assertEqual(counts["PNL0001"], 180) - self.assertEqual(counts["PNCH012"], 80) - self.assertEqual(counts["PNRP17"], 72) - self.assertEqual(counts["PNVO001"], 56) - self.assertEqual(counts["PNOR001"], 48) - self.assertEqual(counts["PNCH023"], 20) - - def test_owner_lookup_returns_data_paths_for_component(self) -> None: - paths = self.catalog.data_paths_for_owner("pn_c1_8") - - self.assertIn("temp3@pn_c1_8", paths) - self.assertIn("press3@pn_c1_8", paths) - self.assertEqual(len(paths), 10) - - def test_split_data_path_rejects_invalid_values(self) -> None: - self.assertEqual(split_data_path("press3@pn_c1_8"), ("press3", "pn_c1_8")) - with self.assertRaises(ValueError): - split_data_path("press3") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_thermofluid_closure_plan.py b/tests/test_thermofluid_closure_plan.py deleted file mode 100644 index cdd2074..0000000 --- a/tests/test_thermofluid_closure_plan.py +++ /dev/null @@ -1,642 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import replace -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -from app.main import compile_reactflow_network, compile_system_xml_network -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.amesim.flow.orifices import ( - AmesimPnor001, - AmesimPnvo001FixedOpening, -) -from app.simulation.components.amesim.flow.pipes import ( - AmesimPnl00r, - AmesimPnl0001, - AmesimPnl0002, -) -from app.simulation.components.amesim.storage.chambers import AmesimPnch023 -from app.simulation.components.experimental.flow.orifice import Orifice -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.components.experimental.storage.tank import Tank -from app.simulation.core.medium import IdealGasMedium -from app.simulation.solvers.algebraic import ( - AlgebraicSolveDiagnostics, - AlgebraicSolveError, -) -from app.simulation.solvers.algebraic_blocks import StreamBlockSolveResult -from app.simulation.solvers.solver import SolveIVPConfig -from app.simulation.systems.generic import ( - GenericFluidSystem, - simulation_preparation_issues, -) -from app.simulation.systems.network import SimulationNetwork -from app.system_xml import validate_system_xml_document -from tests.test_amesim_pnvo001_signal_xml import high_pressure_helium_step_project -from tests.test_amesim_mechanical_xml import elastic_contact_project -from tests.test_generic_system_xml_simulation import chain_project -from tests.test_high_stiffness_explicit_rk45 import short_explicit_rk45_xml - - -class _UnclassifiedCustomOrifice(Orifice): - """A custom subclass must not inherit the catalog purity declaration.""" - - def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None: - super().update_stream_outflows(connected_h) - - -class _InvalidDependencyDeclarationOrifice(Orifice): - PRESSURE_FLOW_DEPENDS_ON_STREAM = 1 - - -class _CountingCylinder(Cylinder): - def __init__(self, *args, **kwargs) -> None: - self.refresh_count = 0 - super().__init__(*args, **kwargs) - - def refresh_thermodynamic_ports(self): - self.refresh_count += 1 - return super().refresh_thermodynamic_ports() - - -def _three_component_network( - middle: Orifice, - *, - prefix: str = "independent", -) -> tuple[SimulationNetwork, _CountingCylinder, Tank]: - medium = IdealGasMedium() - source = _CountingCylinder( - f"{prefix}_source", - medium, - V=0.02, - p0=500_000.0, - T0=320.0, - ) - sink = Tank( - f"{prefix}_sink", - medium, - V=0.05, - p0=100_000.0, - T0=290.0, - ) - network = SimulationNetwork(prefix) - for component in (source, middle, sink): - network.add_component(component) - network.connect(source.name, "port_b", middle.name, "port_a") - network.connect(middle.name, "port_b", sink.name, "port_a") - return network, source, sink - - -def _mixed_island_network( - *, - independent_pressure: float = 450_000.0, -) -> SimulationNetwork: - medium = IdealGasMedium() - sensitive_source = Cylinder( - "sensitive_source", - medium, - V=0.02, - p0=600_000.0, - T0=350.0, - ) - sensitive_sink = Tank( - "sensitive_sink", - medium, - V=0.05, - p0=100_000.0, - T0=280.0, - ) - valve = AmesimPnvo001FixedOpening( - "sensitive_valve", - medium, - area0=1.0e-5, - opening=0.8, - ) - independent_source = Cylinder( - "independent_source", - medium, - V=0.02, - p0=independent_pressure, - T0=310.0, - ) - independent_sink = Tank( - "independent_sink", - medium, - V=0.05, - p0=120_000.0, - T0=295.0, - ) - independent_orifice = Orifice("independent_orifice", K=2.0e-5) - - network = SimulationNetwork("mixed-islands") - for component in ( - sensitive_source, - sensitive_sink, - valve, - independent_source, - independent_sink, - independent_orifice, - ): - network.add_component(component) - network.connect("sensitive_source", "port_b", "sensitive_valve", "port_2") - network.connect("sensitive_valve", "port_3", "sensitive_sink", "port_a") - network.connect("independent_source", "port_b", "independent_orifice", "port_a") - network.connect("independent_orifice", "port_b", "independent_sink", "port_a") - return network - - -def _special_seed_islands_network() -> SimulationNetwork: - medium = IdealGasMedium() - series_source = AmesimPnch023("series_source", medium, p0=15.3e6) - series_source_plug = AmesimPnpl01("series_source_plug") - series_orifice = AmesimPnor001("series_orifice", medium) - series_pipe = AmesimPnl0001("series_pipe", medium, p0=14.0e6) - series_pipe_plug = AmesimPnpl01("series_pipe_plug") - - closed_pipe = AmesimPnl0002("closed_pipe", medium, p0=200_000.0) - closed_pipe_left = AmesimPnpl01("closed_pipe_left") - closed_pipe_right = AmesimPnpl01("closed_pipe_right") - - resistance_source = Cylinder( - "resistance_source", - medium, - V=0.02, - p0=500_000.0, - T0=310.0, - ) - resistance = AmesimPnl00r("resistance", medium) - resistance_plug = AmesimPnpl01("resistance_plug") - - network = SimulationNetwork("special-seed-islands") - for component in ( - series_source, - series_source_plug, - series_orifice, - series_pipe, - series_pipe_plug, - closed_pipe, - closed_pipe_left, - closed_pipe_right, - resistance_source, - resistance, - resistance_plug, - ): - network.add_component(component) - network.connect("series_source_plug", "port_1", "series_source", "port_1") - network.connect("series_source", "port_2", "series_orifice", "port_1") - network.connect("series_orifice", "port_2", "series_pipe", "port_1") - network.connect("series_pipe", "port_2", "series_pipe_plug", "port_1") - network.connect("closed_pipe_left", "port_1", "closed_pipe", "port_1") - network.connect("closed_pipe", "port_2", "closed_pipe_right", "port_1") - network.connect("resistance_source", "port_b", "resistance", "port_1") - network.connect("resistance", "port_2", "resistance_plug", "port_1") - return network - - -def _force_legacy_global_coupling(system: GenericFluidSystem) -> None: - plan = system._thermofluid_closure_plan - system._thermofluid_closure_plan = replace( - plan, - secondary_pressure_solvers=(system.pressure_flow_solver,), - secondary_component_groups=(plan.global_component_group,), - uses_conservative_global_solver=True, - ) - - -class ThermofluidClosurePlanTests(unittest.TestCase): - def test_independent_network_solves_pressure_once_and_refreshes_once(self) -> None: - network, source, _sink = _three_component_network( - Orifice("independent_orifice", K=1.0e-5) - ) - system = GenericFluidSystem(network) - source.refresh_count = 0 - - system.consistent_initial_state_vector() - - self.assertEqual(system._thermofluid_closure_plan.secondary_pressure_solvers, ()) - self.assertEqual(system.algebraic_solve_count, 1) - self.assertEqual(system.thermofluid_pressure_pass_count, 1) - self.assertEqual(source.refresh_count, 1) - - def test_mixed_network_revisits_only_the_stream_sensitive_island(self) -> None: - system = GenericFluidSystem(_mixed_island_network()) - plan = system._thermofluid_closure_plan - - self.assertFalse(plan.uses_conservative_global_solver) - self.assertEqual(len(plan.secondary_pressure_solvers), 1) - self.assertEqual( - set(plan.secondary_component_groups[0]), - {"sensitive_source", "sensitive_sink", "sensitive_valve"}, - ) - self.assertEqual( - set(plan.secondary_pressure_solvers[0].network.components), - set(plan.secondary_component_groups[0]), - ) - self.assertNotIn( - "independent_orifice", - plan.secondary_pressure_solvers[0].network.components, - ) - - state = system.consistent_initial_state_vector() - first = system.rhs(0.0, state) - second = system.rhs(0.0, state) - for first_value, second_value in zip(first, second): - self.assertAlmostEqual(first_value, second_value, delta=1.0e-9) - - def test_secondary_attempt_chain_is_counted_as_one_logical_solve(self) -> None: - system = GenericFluidSystem(_mixed_island_network()) - secondary = system._thermofluid_closure_plan.secondary_block_solvers[0] - aggregate = AlgebraicSolveDiagnostics( - success=True, - message="accepted global fallback", - evaluations=5, - pressure_scale=600_000.0, - flow_scale=0.01, - max_scaled_residual=0.2, - max_raw_residual=2.0, - residual_evaluations=18, - jacobian_mode="dense", - block_fallback_used=True, - block_fallback_reason="blockResidualNotConverged", - ) - fake_result = StreamBlockSolveResult( - diagnostics=(aggregate,), - scopes=(tuple(system.network.components),), - used_global_fallback=True, - ) - initial_diagnostics: list[AlgebraicSolveDiagnostics] = [] - original_initial_solve = system.pressure_flow_solver.solve - - def recorded_initial_solve(*args, **kwargs): - result = original_initial_solve(*args, **kwargs) - initial_diagnostics.append(result) - return result - - with patch.object( - system.pressure_flow_solver, - "solve", - side_effect=recorded_initial_solve, - ), patch.object(secondary, "solve", return_value=fake_result): - system.consistent_initial_state_vector() - - self.assertEqual(len(initial_diagnostics), 1) - initial = initial_diagnostics[0] - self.assertEqual(system.algebraic_solve_count, 2) - self.assertEqual( - system.algebraic_block_fallback_count, - int(initial.block_fallback_used) + 1, - ) - self.assertEqual( - system.algebraic_optimizer_evaluation_count, - initial.evaluations + 5, - ) - self.assertEqual( - system.algebraic_residual_evaluation_count, - initial.residual_evaluations + 18, - ) - - def test_secondary_island_failure_reports_its_physical_scope(self) -> None: - system = GenericFluidSystem(_mixed_island_network()) - plan = system._thermofluid_closure_plan - secondary = plan.secondary_pressure_solvers[0] - - def failed_result(_fun, x0, **_kwargs): - return SimpleNamespace( - x=x0.copy(), - success=False, - status=-1, - message="forced secondary-island failure", - nfev=1, - ) - - with patch.object( - secondary, - "_solve_explicit_flow_unknowns", - return_value=None, - ), patch("scipy.optimize.least_squares", side_effect=failed_result): - with self.assertRaises(AlgebraicSolveError) as raised: - secondary.solve(effort_variables=()) - - self.assertEqual(raised.exception.scope_kind, "physicalIsland") - self.assertEqual( - raised.exception.scope_components, - plan.secondary_component_groups[0], - ) - - def test_secondary_islands_preserve_special_pressure_seed_plans(self) -> None: - network = _special_seed_islands_network() - system = GenericFluidSystem(network) - plan = system._thermofluid_closure_plan - solvers = { - frozenset(solver.network.components): solver - for solver in plan.secondary_pressure_solvers - } - - series_solver = solvers[ - frozenset( - { - "series_source", - "series_source_plug", - "series_orifice", - "series_pipe", - "series_pipe_plug", - } - ) - ] - closed_pipe_solver = solvers[ - frozenset( - {"closed_pipe", "closed_pipe_left", "closed_pipe_right"} - ) - ] - resistance_solver = solvers[ - frozenset( - { - "resistance_source", - "resistance", - "resistance_plug", - } - ) - ] - - self.assertEqual(len(series_solver._resistance_pnl0001_series_plan), 1) - self.assertEqual(len(closed_pipe_solver._closed_resistance_pressure_plan), 2) - self.assertEqual(len(resistance_solver._closed_resistance_pressure_plan), 1) - - closed_pipe = network.components["closed_pipe"] - expected_pressure = closed_pipe.properties().p - closed_pipe.port_1.p = 10_000.0 - closed_pipe.port_2.p = 20_000.0 - network.components["closed_pipe_left"].port_1.p = 30_000.0 - network.components["closed_pipe_right"].port_1.p = 40_000.0 - - closed_pipe_solver._seed_closed_resistance_pressures() - - self.assertAlmostEqual(closed_pipe.port_1.p, expected_pressure) - self.assertAlmostEqual(closed_pipe.port_2.p, expected_pressure) - self.assertAlmostEqual( - network.components["closed_pipe_left"].port_1.p, - expected_pressure, - ) - self.assertAlmostEqual( - network.components["closed_pipe_right"].port_1.p, - expected_pressure, - ) - - def test_unclassified_custom_stream_component_uses_legacy_global_solver(self) -> None: - network, _source, _sink = _three_component_network( - _UnclassifiedCustomOrifice("custom_orifice", K=1.0e-5), - prefix="custom", - ) - system = GenericFluidSystem(network) - plan = system._thermofluid_closure_plan - - self.assertTrue(plan.uses_conservative_global_solver) - self.assertEqual(plan.secondary_pressure_solvers, (system.pressure_flow_solver,)) - self.assertEqual(plan.secondary_component_groups, (plan.global_component_group,)) - - def test_invalid_dependency_declaration_uses_legacy_global_solver(self) -> None: - network, _source, _sink = _three_component_network( - _InvalidDependencyDeclarationOrifice("invalid_orifice", K=1.0e-5), - prefix="invalid", - ) - plan = GenericFluidSystem(network)._thermofluid_closure_plan - - self.assertTrue(plan.uses_conservative_global_solver) - self.assertEqual( - plan.conservative_fallback_reason, - "invalidDependencyDeclaration", - ) - - def test_non_square_physical_island_metadata_forces_global_fallback(self) -> None: - system = GenericFluidSystem(_mixed_island_network()) - templates = list(system.pressure_flow_solver.equation_templates) - moved = next( - index - for index, equation in enumerate(templates) - if equation.owner == "component" - and equation.owner_id == "independent_orifice" - ) - equation = templates[moved] - templates[moved] = replace( - equation, - owner_id="sensitive_valve", - variables=tuple( - variable.replace("independent_orifice", "sensitive_valve") - for variable in equation.variables - ), - ) - system.pressure_flow_solver._equation_templates = tuple(templates) - - plan = system._build_thermofluid_closure_plan() - - self.assertTrue(plan.uses_conservative_global_solver) - self.assertEqual( - plan.conservative_fallback_reason, - "nonSquarePhysicalIsland", - ) - - def test_pruned_and_legacy_chain_results_are_numerically_equivalent(self) -> None: - config = SolveIVPConfig( - t_start=0.0, - t_stop=0.01, - method="BDF", - max_step=0.001, - ) - optimized = GenericFluidSystem(compile_reactflow_network(chain_project())) - legacy = GenericFluidSystem(compile_reactflow_network(chain_project())) - _force_legacy_global_coupling(legacy) - - optimized_result = optimized.simulate(config, sample_step=0.005) - legacy_result = legacy.simulate(config, sample_step=0.005) - - self.assertTrue(optimized_result.success) - self.assertTrue(legacy_result.success) - self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys()) - for key, optimized_values in optimized_result.series.items(): - legacy_values = legacy_result.series[key] - self.assertEqual(len(optimized_values), len(legacy_values), key) - for optimized_value, legacy_value in zip( - optimized_values, - legacy_values, - ): - self.assertAlmostEqual( - optimized_value, - legacy_value, - delta=1.0e-11 * max(abs(legacy_value), 1.0), - msg=key, - ) - self.assertEqual(optimized_result.final.keys(), legacy_result.final.keys()) - for key, optimized_value in optimized_result.final.items(): - legacy_value = legacy_result.final[key] - self.assertAlmostEqual( - optimized_value, - legacy_value, - delta=1.0e-11 * max(abs(legacy_value), 1.0), - msg=key, - ) - self.assertLess(optimized.algebraic_solve_count, legacy.algebraic_solve_count) - - def test_stream_dependent_rhs_is_history_independent_after_other_trial(self) -> None: - network_a = compile_reactflow_network(high_pressure_helium_step_project()) - network_fresh = compile_reactflow_network(high_pressure_helium_step_project()) - system = GenericFluidSystem(network_a) - fresh = GenericFluidSystem(network_fresh) - state = system.consistent_initial_state_vector() - fresh_state = fresh.consistent_initial_state_vector() - perturbed = list(state) - perturbed[0] *= 1.000001 - perturbed[1] *= 0.999999 - - first = system.rhs(0.041, state) - system.rhs(0.041, perturbed) - repeated = system.rhs(0.041, state) - reference = fresh.rhs(0.041, fresh_state) - - for expected, actual in zip(first, repeated): - self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0)) - for expected, actual in zip(reference, repeated): - self.assertAlmostEqual(actual, expected, delta=1.0e-10 * max(abs(expected), 1.0)) - - def test_block_solve_reuses_global_scales_from_extreme_other_island(self) -> None: - optimized = GenericFluidSystem( - _mixed_island_network(independent_pressure=1.0e10) - ) - legacy = GenericFluidSystem( - _mixed_island_network(independent_pressure=1.0e10) - ) - _force_legacy_global_coupling(legacy) - optimized_state = optimized.initial_state_vector() - legacy_state = legacy.initial_state_vector() - - optimized_rhs = optimized.rhs(0.0, optimized_state) - legacy_rhs = legacy.rhs(0.0, legacy_state) - - for expected, actual in zip(legacy_rhs, optimized_rhs): - self.assertAlmostEqual( - actual, - expected, - delta=1.0e-10 * max(abs(expected), 1.0), - ) - self.assertLessEqual( - optimized.max_algebraic_residual, - max(legacy.max_algebraic_residual, 1.0e-14), - ) - - def test_high_stiffness_contact_island_matches_legacy_global_closure(self) -> None: - report = validate_system_xml_document(short_explicit_rk45_xml()) - self.assertTrue(report.valid) - assert report.document is not None - document = report.document - optimized = GenericFluidSystem(compile_system_xml_network(document)) - legacy = GenericFluidSystem(compile_system_xml_network(document)) - _force_legacy_global_coupling(legacy) - config = SolveIVPConfig( - t_start=document.simulation.t_start, - t_stop=document.simulation.t_stop, - method=document.simulation.method, - rtol=1.0e-6, - max_step=document.simulation.max_step, - ) - - optimized_result = optimized.simulate( - config, - sample_step=document.simulation.sample_step, - ) - legacy_result = legacy.simulate( - config, - sample_step=document.simulation.sample_step, - ) - - self.assertTrue(optimized_result.success) - self.assertTrue(legacy_result.success) - optimized_totals = optimized_result.diagnostics["integration"]["totals"] - legacy_totals = legacy_result.diagnostics["integration"]["totals"] - self.assertEqual( - optimized_totals["stateTransitionCount"], - legacy_totals["stateTransitionCount"], - ) - self.assertEqual(optimized_result.series.keys(), legacy_result.series.keys()) - for key, optimized_values in optimized_result.series.items(): - legacy_values = legacy_result.series[key] - self.assertEqual(len(optimized_values), len(legacy_values), key) - for optimized_value, legacy_value in zip( - optimized_values, - legacy_values, - ): - self.assertAlmostEqual( - optimized_value, - legacy_value, - delta=2.0e-7 * max(abs(legacy_value), 1.0), - msg=key, - ) - - def test_secondary_fluid_island_does_not_clear_active_contact_state(self) -> None: - network = compile_reactflow_network(elastic_contact_project()) - medium = IdealGasMedium() - source = Cylinder( - "separate_source", - medium, - V=0.02, - p0=600_000.0, - T0=350.0, - ) - sink = Tank( - "separate_sink", - medium, - V=0.05, - p0=100_000.0, - T0=280.0, - ) - valve = AmesimPnvo001FixedOpening( - "separate_valve", - medium, - area0=1.0e-5, - opening=0.8, - ) - for component in (source, sink, valve): - network.add_component(component) - network.connect("separate_source", "port_b", "separate_valve", "port_2") - network.connect("separate_valve", "port_3", "separate_sink", "port_a") - self.assertEqual(simulation_preparation_issues(network), ()) - system = GenericFluidSystem(network) - contact = network.components["contact_1"] - secondary = system._thermofluid_closure_plan.secondary_block_solvers[0] - original_solve = secondary.solve - observed: list[tuple[float | None, ...]] = [] - - def checked_solve(*, scale_context=None): - if contact._causal_penetration is None: - contact.set_causal_contact(penetration=1.0e-4, force=10.0) - before = ( - contact._causal_penetration, - contact._causal_contact_force, - contact._causal_port_1_x, - contact._causal_port_2_x, - contact._causal_port_1_v, - contact._causal_port_2_v, - ) - result = original_solve(scale_context=scale_context) - after = ( - contact._causal_penetration, - contact._causal_contact_force, - contact._causal_port_1_x, - contact._causal_port_2_x, - contact._causal_port_1_v, - contact._causal_port_2_v, - ) - self.assertEqual(after, before) - observed.append(before) - return result - - secondary.solve = checked_solve - system.consistent_initial_state_vector() - - self.assertTrue(observed) - self.assertIsNotNone(observed[0][0]) - self.assertIsNotNone(observed[0][1]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_thermofluid_recovery.py b/tests/test_thermofluid_recovery.py deleted file mode 100644 index f513e0a..0000000 --- a/tests/test_thermofluid_recovery.py +++ /dev/null @@ -1,339 +0,0 @@ -from __future__ import annotations - -from time import perf_counter -import unittest -from unittest.mock import patch - -from app.simulation.components.amesim.boundary.sources import AmesimPnpl01 -from app.simulation.components.amesim.flow.pipes import AmesimPnl00r -from app.simulation.components.experimental.storage.cylinder import Cylinder -from app.simulation.core.errors import RecoverableTrialStateError -from app.simulation.core.medium import IdealGasMedium -from app.simulation.solvers.algebraic import ( - AlgebraicSolveDiagnostics, - AlgebraicSolveError, -) -from app.simulation.solvers.algebraic_blocks import StreamBlockSolveResult -from app.simulation.solvers.solver import SolveIVPConfig -from app.simulation.solvers.stream import StreamSolveDiagnostics, StreamSolveError -from app.simulation.solvers.thermofluid import ( - ThermofluidClosureError, - ThermofluidTransactionPlan, -) -from app.simulation.systems.generic import GenericFluidSystem -from app.simulation.systems.network import SimulationNetwork - - -def _pnl00r_system() -> tuple[GenericFluidSystem, AmesimPnl00r]: - medium = IdealGasMedium() - source = Cylinder( - "source", - medium, - V=0.02, - p0=500_000.0, - T0=310.0, - ) - resistance = AmesimPnl00r("resistance", medium) - resistance._causal_test_seed = {"accepted": 7.0} - plug = AmesimPnpl01("plug") - network = SimulationNetwork("thermofluid-rollback") - for component in (source, resistance, plug): - network.add_component(component) - network.connect("source", "port_b", "resistance", "port_1") - network.connect("resistance", "port_2", "plug", "port_1") - return GenericFluidSystem(network), resistance - - -def _physical_values(system: GenericFluidSystem) -> tuple[float, ...]: - return tuple( - float(getattr(binding.state, binding.variable)) - for binding in system._thermofluid_transaction_plan.port_value_bindings - ) - - -class ThermofluidRecoveryTests(unittest.TestCase): - def test_nonconvergence_is_recoverable_diagnostic_and_fully_restored(self) -> None: - system, resistance = _pnl00r_system() - state = system.consistent_initial_state_vector() - system.rhs(0.01, state) - secondary = system._thermofluid_closure_plan.secondary_block_solvers[0] - target = resistance.port_1 - - expected_ports = _physical_values(system) - expected_connected_h = dict(resistance._connected_h) - expected_causal_seed = dict(resistance._causal_test_seed) - expected_owner_diagnostics = tuple( - owner.last_diagnostics - for owner in system._thermofluid_transaction_plan.diagnostic_owners - ) - expected_last_algebraic = system._last_algebraic_diagnostics - expected_last_scope = system._last_algebraic_scope - expected_last_success = ( - system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"] - ) - - fake_diagnostics = AlgebraicSolveDiagnostics( - success=True, - message="forced divergent thermofluid pass", - evaluations=0, - pressure_scale=1.0, - flow_scale=1.0, - max_scaled_residual=0.0, - max_raw_residual=0.0, - ) - call_count = 0 - - def divergent_solve(*, scale_context=None): - del scale_context - nonlocal call_count - call_count += 1 - target.m_flow += 1.0 - resistance._connected_h = { - "port_1": -float(call_count), - "port_2": -2.0 * float(call_count), - } - resistance._causal_test_seed["polluted"] = float(call_count) - return StreamBlockSolveResult( - diagnostics=(fake_diagnostics,), - scopes=( - system._thermofluid_closure_plan.secondary_component_groups[0], - ), - used_global_fallback=False, - ) - - with patch.object(secondary, "solve", side_effect=divergent_solve): - with self.assertRaises(ThermofluidClosureError) as raised: - system.rhs(0.125, state) - - error = raised.exception - self.assertIsInstance(error, RecoverableTrialStateError) - failure = error.diagnostics - self.assertEqual(call_count, 25) - self.assertEqual(failure.failed_rhs_time, 0.125) - self.assertEqual(failure.iterations, 25) - self.assertEqual(failure.failure_count, 1) - self.assertEqual(len(failure.delta_tail), 8) - self.assertEqual( - [item.iteration for item in failure.delta_tail], - list(range(18, 26)), - ) - self.assertEqual(failure.max_delta, 1.0) - self.assertEqual(failure.scale, 25.0) - self.assertEqual(failure.tolerance, 25.0e-12) - self.assertIsNotNone(failure.worst_port) - assert failure.worst_port is not None - self.assertEqual(failure.worst_port.component, "resistance") - self.assertEqual(failure.worst_port.port, "port_1") - self.assertEqual(failure.worst_port.signed_delta, 1.0) - - self.assertEqual(_physical_values(system), expected_ports) - self.assertEqual(resistance._connected_h, expected_connected_h) - self.assertEqual(resistance._causal_test_seed, expected_causal_seed) - self.assertEqual( - tuple( - owner.last_diagnostics - for owner in system._thermofluid_transaction_plan.diagnostic_owners - ), - expected_owner_diagnostics, - ) - self.assertIs(system._last_algebraic_diagnostics, expected_last_algebraic) - self.assertEqual(system._last_algebraic_scope, expected_last_scope) - self.assertTrue(system.pressure_flow_solver._causal_audit_required) - self.assertTrue(secondary._causal_audit_required) - - outcomes = system._thermofluid_closure_diagnostics.as_dict() - self.assertEqual(outcomes["failureCount"], 1) - self.assertEqual(outcomes["lastSuccess"], expected_last_success) - self.assertEqual(outcomes["lastFailure"], failure.as_dict()) - - # A successful maintenance/postprocessing closure must not masquerade - # as the last successful integrator RHS. - system._close_current_state(0.5) - self.assertEqual( - system._thermofluid_closure_diagnostics.as_dict()["lastSuccess"], - expected_last_success, - ) - - # The restored seed is safe to replay at the failed time. - retried = system.rhs(0.125, state) - self.assertEqual(len(retried), len(state)) - outcomes = system._thermofluid_closure_diagnostics.as_dict() - self.assertEqual(outcomes["failureCount"], 1) - self.assertEqual(outcomes["lastFailure"], failure.as_dict()) - self.assertEqual(outcomes["lastSuccess"]["rhsTime"], 0.125) - - def test_simulation_result_exposes_closure_and_transaction_diagnostics(self) -> None: - system, _resistance = _pnl00r_system() - result = system.simulate( - SolveIVPConfig( - t_start=0.0, - t_stop=1.0e-4, - method="RK45", - max_step=1.0e-4, - ), - sample_step=1.0e-4, - ) - - self.assertTrue(result.success) - closure = result.diagnostics["stream"]["thermofluidClosure"] - self.assertEqual(closure["failureCount"], 0) - self.assertIsNone(closure["lastFailure"]) - self.assertIsNotNone(closure["lastSuccess"]) - self.assertEqual( - closure["transaction"]["physicalPortValueSlotCount"], - 20, - ) - self.assertEqual( - closure["transaction"]["physicalFlowPortCount"], - 4, - ) - - def test_eventless_generic_simulation_retries_a_recoverable_trial(self) -> None: - system, _resistance = _pnl00r_system() - self.assertFalse(system.mechanical_state_reducer.has_state_events) - self.assertEqual(system.signal_resolver.event_times(0.0, 1.0e-4), ()) - - original_rhs = system.rhs - failed_once = False - - def fail_first_positive_trial(time, state): - nonlocal failed_once - if time > 0.0 and not failed_once: - failed_once = True - raise RecoverableTrialStateError("forced recoverable trial") - return original_rhs(time, state) - - with patch.object(system, "rhs", side_effect=fail_first_positive_trial): - result = system.simulate( - SolveIVPConfig( - t_start=0.0, - t_stop=1.0e-4, - method="RK45", - max_step=1.0e-4, - ), - sample_step=1.0e-4, - ) - - self.assertTrue(failed_once) - self.assertTrue(result.success) - totals = result.diagnostics["integration"]["totals"] - self.assertEqual(totals["recoverableRetryCount"], 1) - self.assertEqual(totals["stateTransitionCount"], 0) - segment = result.diagnostics["integration"]["segments"][0] - self.assertEqual(len(segment["recoverableRetries"]), 1) - self.assertEqual( - segment["recoverableRetries"][0]["reason"], - "forced recoverable trial", - ) - - def test_other_closure_failures_restore_but_remain_nonrecoverable(self) -> None: - fake_algebraic_diagnostics = AlgebraicSolveDiagnostics( - success=False, - message="forced algebraic failure", - evaluations=1, - pressure_scale=1.0, - flow_scale=1.0, - max_scaled_residual=2.0, - max_raw_residual=2.0, - ) - - for failure_kind in ("stream", "secondaryAlgebraic"): - with self.subTest(failure_kind=failure_kind): - system, resistance = _pnl00r_system() - state = system.consistent_initial_state_vector() - system.rhs(0.01, state) - expected_ports = _physical_values(system) - expected_cache = dict(resistance._connected_h) - expected_success = ( - system._thermofluid_closure_diagnostics.as_dict()[ - "lastSuccess" - ] - ) - - def pollute() -> None: - resistance.port_1.p = -9.0 - resistance.port_1.m_flow = 99.0 - resistance.port_1.h_outflow = -999.0 - resistance._connected_h = { - "port_1": -1.0, - "port_2": -2.0, - } - - if failure_kind == "stream": - def failed_stream(*, dynamic_ports_are_current=False): - del dynamic_ports_are_current - pollute() - diagnostics = StreamSolveDiagnostics( - converged=False, - iterations=100, - max_delta=1.0, - ) - raise StreamSolveError("forced stream failure", diagnostics) - - context = patch.object( - system.stream_resolver, - "solve", - side_effect=failed_stream, - ) - expected_error = StreamSolveError - else: - secondary = ( - system._thermofluid_closure_plan.secondary_block_solvers[0] - ) - - def failed_secondary(*, scale_context=None): - del scale_context - pollute() - raise AlgebraicSolveError( - "forced secondary failure", - fake_algebraic_diagnostics, - scope_kind="physicalIsland", - ) - - context = patch.object( - secondary, - "solve", - side_effect=failed_secondary, - ) - expected_error = AlgebraicSolveError - - with context: - with self.assertRaises(expected_error) as raised: - system.rhs(0.25, state) - - self.assertNotIsInstance( - raised.exception, - RecoverableTrialStateError, - ) - self.assertEqual(_physical_values(system), expected_ports) - self.assertEqual(resistance._connected_h, expected_cache) - outcomes = system._thermofluid_closure_diagnostics.as_dict() - self.assertEqual(outcomes["failureCount"], 0) - self.assertIsNone(outcomes["lastFailure"]) - self.assertEqual(outcomes["lastSuccess"], expected_success) - - def test_transaction_microbenchmark_stays_scoped_to_numeric_slots(self) -> None: - medium = IdealGasMedium() - network = SimulationNetwork("transaction-microbenchmark") - for index in range(120): - network.add_component(AmesimPnl00r(f"resistance_{index}", medium)) - plan = ThermofluidTransactionPlan.compile(network) - diagnostics = plan.diagnostics() - self.assertEqual(diagnostics["physicalPortValueSlotCount"], 1_200) - self.assertEqual(diagnostics["streamAndCausalCacheSlotCount"], 120) - - for _ in range(5): - plan.capture().restore() - repetitions = 200 - started = perf_counter() - for _ in range(repetitions): - plan.capture().restore() - seconds_per_transaction = (perf_counter() - started) / repetitions - - # This deliberately generous guard detects accidental full component/ - # network deepcopy while remaining stable on slow CI workers. - self.assertLess(seconds_per_transaction, 0.01) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_three_piston_tangent.py b/tests/test_three_piston_tangent.py deleted file mode 100644 index 882f4d5..0000000 --- a/tests/test_three_piston_tangent.py +++ /dev/null @@ -1,337 +0,0 @@ -from pathlib import Path -import os -import unittest -from unittest.mock import patch - -import numpy as np - -from app.main import compile_system_xml_network -from app.simulation.solvers.jacobian import ExactColumnsUnavailable -from app.simulation.solvers.mechanical import MechanicalConstraintGroup -from app.simulation.solvers.solver import ODESolution, SolveIVPConfig -from app.simulation.solvers.tangent import compile_three_piston_tangent_provider -from app.simulation.systems.generic import ( - ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE, - GenericFluidSystem, - _requested_ode_jacobian_mode, -) -from app.system_xml import validate_system_xml_document - - -TARGET_XML = Path("tests/data/test_mql-full-branches-01-04.xml") -TARGET_MASS_NAMES = ( - "mass_friction_endstops_10", - "mass_friction_endstops_11", - "mass_friction_endstops_12", -) - - -class ThreePistonTangentCompilerTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - report = validate_system_xml_document(TARGET_XML.read_bytes()) - assert report.valid - with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): - cls.system = GenericFluidSystem( - compile_system_xml_network(report.document) - ) - cls.initial_state = np.asarray( - cls.system.consistent_initial_state_vector(0.0), - dtype=float, - ) - - def setUp(self) -> None: - self.system.mechanical_state_reducer.reset_constraint_modes() - self.system.apply_state_vector(self.initial_state.tolist()) - - def _closed_primal( - self, - state: np.ndarray, - ) -> tuple[dict[str, dict[str, float]], np.ndarray]: - self.system.apply_state_vector(state.tolist()) - connected_h = self.system._close_current_state(0.0) - derivative = np.asarray( - self.system._state_derivatives(connected_h), - dtype=float, - ) - return connected_h, derivative - - def test_target_layout_is_resolved_from_owner_and_slot(self) -> None: - compilation = compile_three_piston_tangent_provider(self.system) - self.assertTrue(compilation.eligible, compilation.reason) - - owner_slots: dict[str, tuple[int, int]] = {} - cursor = 0 - for entry in self.system.mechanical_state_reducer.state_entries: - if isinstance(entry, MechanicalConstraintGroup): - owner_slots[entry.representative.name] = (cursor, cursor + 1) - cursor += 2 - else: - cursor += entry.state_size - resolved = tuple( - sorted( - index - for name in TARGET_MASS_NAMES - for index in owner_slots[name] - ) - ) - - self.assertEqual(compilation.columns, resolved) - # This is a fixture drift guard, not the provider's lookup mechanism. - self.assertEqual(resolved, (20, 21, 38, 39, 54, 55)) - self.assertEqual(compilation.reached_assignment_count, 34) - - def test_stale_primal_context_requests_typed_fallback(self) -> None: - compilation = compile_three_piston_tangent_provider(self.system) - self.assertTrue(compilation.eligible, compilation.reason) - assert compilation.provider is not None - - with self.assertRaises(ExactColumnsUnavailable) as captured: - compilation.provider( - 0.0, - self.initial_state.copy(), - compilation.columns, - ) - self.assertEqual(captured.exception.reason, "stalePrimalContext") - - def test_initial_contact_boundary_requests_numerical_columns(self) -> None: - compilation = compile_three_piston_tangent_provider(self.system) - self.assertTrue(compilation.eligible, compilation.reason) - assert compilation.provider is not None - connected_h, _derivative = self._closed_primal( - self.initial_state.copy() - ) - compilation.provider.request_primal_capture() - compilation.provider.record_primal( - 0.0, - self.initial_state, - connected_h, - ) - - with self.assertRaises(ExactColumnsUnavailable) as captured: - compilation.provider( - 0.0, - self.initial_state.copy(), - compilation.columns, - ) - self.assertEqual( - captured.exception.reason, - "contactMode:contact_mode_boundary", - ) - - def test_smooth_six_columns_match_full_rhs_centered_difference(self) -> None: - compilation = compile_three_piston_tangent_provider(self.system) - self.assertTrue(compilation.eligible, compilation.reason) - provider = compilation.provider - assert provider is not None - - state = self.initial_state.copy() - # Move all three contacts away from gap==0 and all three PNL0001 laws - # away from equal-pressure/zero-flow. The selected columns themselves - # remain the six mechanical [v, x] seeds. - for branch in provider.branches: - chamber_offset, chamber_size = provider.state_offsets[ - branch.chamber.name - ] - self.assertEqual(chamber_size, 2) - state[chamber_offset] *= 1.01 - state[branch.position_index] -= 1.0e-3 - - connected_h, _base = self._closed_primal(state) - provider.request_primal_capture() - provider.record_primal(0.0, state, connected_h) - exact = provider(0.0, state.copy(), compilation.columns) - - numerical = np.empty_like(exact) - for local_column, state_index in enumerate(compilation.columns): - step = 1.0e-7 * max(abs(state[state_index]), 1.0) - lower = state.copy() - upper = state.copy() - lower[state_index] -= step - upper[state_index] += step - lower_rhs = np.asarray( - self.system.rhs(0.0, lower.tolist()), - dtype=float, - ) - upper_rhs = np.asarray( - self.system.rhs(0.0, upper.tolist()), - dtype=float, - ) - numerical[:, local_column] = ( - upper_rhs - lower_rhs - ) / (2.0 * step) - - np.testing.assert_allclose( - exact, - numerical, - rtol=2.0e-6, - atol=1.0e-5, - ) - for downstream_mass in ( - "mass_friction_endstops_18", - "mass_friction_endstops_19", - ): - offset, size = provider.state_offsets[downstream_mass] - self.assertEqual(size, 2) - # Causal force reach includes both remote masses, but this fixture - # holds their discrete constraints fixed, so final ODE rows are zero. - np.testing.assert_allclose( - exact[offset : offset + size, :], - numerical[offset : offset + size, :], - rtol=0.0, - atol=1.0e-12, - ) - np.testing.assert_allclose( - exact[offset : offset + size, :], - 0.0, - rtol=0.0, - atol=1.0e-12, - ) - - def test_disabled_causal_path_is_not_eligible(self) -> None: - solver = self.system.pressure_flow_solver - original = solver._causal_fast_path_environment_enabled - try: - solver._causal_fast_path_environment_enabled = False - compilation = compile_three_piston_tangent_provider(self.system) - finally: - solver._causal_fast_path_environment_enabled = original - self.assertFalse(compilation.eligible) - self.assertEqual(compilation.reason, "causalFastPathDisabled") - - def test_semi_analytic_mode_is_explicit_opt_in(self) -> None: - with patch.dict( - os.environ, - {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, - ): - self.assertEqual( - _requested_ode_jacobian_mode(), - "semi-analytic", - ) - with patch.dict(os.environ, {}, clear=True): - self.assertEqual(_requested_ode_jacobian_mode(), "scipy") - - def test_generic_simulation_wires_exact_columns_and_cleans_provider( - self, - ) -> None: - report = validate_system_xml_document(TARGET_XML.read_bytes()) - assert report.valid and report.document is not None - with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): - system = GenericFluidSystem( - compile_system_xml_network(report.document) - ) - captured: dict[str, object] = {} - - def fake_integrate_ode(**options) -> ODESolution: - captured.update(options) - captured["activeProvider"] = system._ode_tangent_provider - initial = [float(value) for value in options["initial_state"]] - stop = float(options["config"].t_stop) - return ODESolution( - t=[0.0, stop], - y=[[value, value] for value in initial], - success=True, - message="test", - ) - - with patch.dict( - os.environ, - {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, - ), patch( - "app.simulation.systems.generic.integrate_ode", - side_effect=fake_integrate_ode, - ): - result = system.simulate( - SolveIVPConfig(t_stop=1.0e-3, method="BDF"), - sample_step=1.0e-3, - ) - - jacobian = captured["jac"] - self.assertIsNotNone(jacobian) - self.assertIsNotNone(captured["activeProvider"]) - self.assertIsNone(system._ode_tangent_provider) - runtime = result.diagnostics["integration"]["jacobian"] - self.assertEqual(runtime["mode"], "semiAnalyticExactColumns") - self.assertEqual(runtime["effectiveMode"], "notEvaluated") - self.assertEqual(runtime["originalColorGroupCount"], 31) - # Generic wiring uses topology discovery rather than the legacy - # three-name wrapper. This fixture contains one additional supported - # branch, so all four branches are compiled automatically. - self.assertEqual(runtime["remainingColorGroupCount"], 24) - self.assertEqual(runtime["exactColumnCount"], 8) - - def test_generic_simulation_ineligible_path_uses_native_scipy(self) -> None: - report = validate_system_xml_document(TARGET_XML.read_bytes()) - assert report.valid and report.document is not None - with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): - system = GenericFluidSystem( - compile_system_xml_network(report.document) - ) - system.pressure_flow_solver._causal_fast_path_environment_enabled = False - captured: dict[str, object] = {} - - def fake_integrate_ode(**options) -> ODESolution: - captured.update(options) - captured["activeProvider"] = system._ode_tangent_provider - initial = [float(value) for value in options["initial_state"]] - stop = float(options["config"].t_stop) - return ODESolution( - t=[0.0, stop], - y=[[value, value] for value in initial], - success=True, - message="test", - ) - - with patch.dict( - os.environ, - {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, - ), patch( - "app.simulation.systems.generic.integrate_ode", - side_effect=fake_integrate_ode, - ): - result = system.simulate( - SolveIVPConfig(t_stop=1.0e-3, method="BDF"), - sample_step=1.0e-3, - ) - - self.assertIsNone(captured["jac"]) - self.assertIsNone(captured["activeProvider"]) - self.assertIsNone(system._ode_tangent_provider) - runtime = result.diagnostics["integration"]["jacobian"] - self.assertEqual(runtime["mode"], "scipySparseFiniteDifference") - self.assertEqual( - runtime["fallbackReason"], - "semiAnalytic:causalFastPathDisabled", - ) - - def test_generic_simulation_cleans_provider_after_integration_error( - self, - ) -> None: - report = validate_system_xml_document(TARGET_XML.read_bytes()) - assert report.valid and report.document is not None - with patch.dict(os.environ, {"SIMULATION_CAUSAL_FAST_PATH": "1"}): - system = GenericFluidSystem( - compile_system_xml_network(report.document) - ) - - def fail_integration(**_options): - self.assertIsNotNone(system._ode_tangent_provider) - raise RuntimeError("injected integration failure") - - with patch.dict( - os.environ, - {ODE_JACOBIAN_MODE_ENVIRONMENT_VARIABLE: "semi-analytic"}, - ), patch( - "app.simulation.systems.generic.integrate_ode", - side_effect=fail_integration, - ), self.assertRaisesRegex(RuntimeError, "injected integration failure"): - system.simulate( - SolveIVPConfig(t_stop=1.0e-3, method="BDF"), - sample_step=1.0e-3, - ) - - self.assertIsNone(system._ode_tangent_provider) - - -if __name__ == "__main__": - unittest.main()