From f725f038b6a740e860987a70dc9a4d36a3d08f40 Mon Sep 17 00:00:00 2001 From: huojiarong Date: Tue, 18 Aug 2026 09:30:29 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20PNL00R=20=E6=91=A9?= =?UTF-8?q?=E6=93=A6=E6=A8=A1=E5=9E=8B=E4=B8=8E=E5=85=AB=E8=B7=AF=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/amesim/flow/pipes.py | 73 +++-- app/simulation/solvers/algebraic.py | 54 +++- .../amesim-component-migration-matrix.md | 4 +- .../simulation/test_mql_8/manifest.json | 70 +++-- tests/test_amesim_pnl00r_component.py | 266 +++++++++++++++++- tests/test_benchmark_regression.py | 16 +- tests/test_causal_numeric_ir.py | 20 +- ...est_pressure_flow_solver_initialization.py | 57 ++++ tests/test_supported_piston_tangent.py | 10 +- tests/test_test_mql_8_regression.py | 26 +- 10 files changed, 498 insertions(+), 98 deletions(-) diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index 1330ae8..d546a67 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -54,6 +54,22 @@ class Pnl0001DerivativeLinearization: _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 _reported_friction_factor(value: float) -> float: return min(float(value), _MAX_REPORTED_FRICTION_FACTOR) @@ -75,8 +91,8 @@ class AmesimPnl00r(AlgebraicComponent): """AMESim PNL00R pneumatic pipe friction resistance. The public model exposes the AMESim PNL00R catalog/XML contract and uses - an auditable Darcy-Weisbach resistance with Reynolds/roughness-dependent - friction. Exact `pn2pipefr_` parity is left for the later model tuning pass. + the `pn2pipefr` compressible gas relation with a Reynolds/roughness- + dependent equivalent flow coefficient. """ MODEL_TYPE = "amesim_pnl00r" @@ -244,7 +260,7 @@ class AmesimPnl00r(AlgebraicComponent): if reynolds_number <= 0.0: return 64_000_000.0 laminar = 64.0 / reynolds_number - if reynolds_number <= 2300.0: + if reynolds_number <= _PN2PIPEFR_TRANSITION_START_REYNOLDS: return laminar # pn2pipefr does not apply the fully rough correction at every @@ -274,10 +290,13 @@ class AmesimPnl00r(AlgebraicComponent): turbulent = smooth_turbulent + roughness_weight * ( fully_rough - smooth_turbulent ) - if reynolds_number >= 4000.0: - return turbulent - fraction = (reynolds_number - 2300.0) / 1700.0 - return laminar + fraction**0.58 * (turbulent - laminar) + 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, @@ -329,13 +348,17 @@ class AmesimPnl00r(AlgebraicComponent): 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_pressure = max(p_1, p_2, 1.0) - upstream_temperature = self._port_temperature("port_1" if pressure_difference > 0.0 else "port_2") - density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12) - magnitude = self._mass_flow_for_pressure_drop( - abs(pressure_difference), - density=density, - temperature=upstream_temperature, + 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 @@ -347,17 +370,19 @@ class AmesimPnl00r(AlgebraicComponent): ) 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(self.area * upstream_pressure, 1.0e-18) + / max(flow_coefficient * self.area * upstream_pressure, 1.0e-18) ) return { "re": reynolds, "cm": cm, "v": velocity, - "ff": _reported_friction_factor(self.friction_factor(reynolds)), + "ff": _reported_friction_factor(friction), } def pressure_flow_equation_values(self) -> tuple[float, ...]: @@ -754,6 +779,8 @@ class AmesimPnl0001(ThermodynamicVolumeComponent): downstream_pressure: float, upstream_temperature: float, resistance_length: float, + max_iterations: int = 16, + analytic_laminar: bool = False, ) -> float: """AMESim pn2pipefr-style compressible friction flow.""" @@ -824,6 +851,18 @@ class AmesimPnl0001(ThermodynamicVolumeComponent): value *= tanh(max(smoothing_argument, 0.0)) return value + if analytic_laminar: + flow_parameter = mass_flow_parameter(pressure_ratio) + viscosity = self._dynamic_viscosity(T_up) + laminar_mass_flow = ( + self.area * p_up * flow_parameter + ) ** 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 + def target_flow(mass_flow: float) -> float: reynolds = self.reynolds_number(mass_flow, T_up) friction = self.friction_factor(reynolds) @@ -846,7 +885,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent): * mass_flow_parameter(pressure_ratio) / sqrt(T_up) ) - for _iteration in range(16): + for _iteration in range(max_iterations): next_magnitude = target_flow(magnitude) if abs(next_magnitude - magnitude) <= max( 1.0e-12, diff --git a/app/simulation/solvers/algebraic.py b/app/simulation/solvers/algebraic.py index c6af17f..238ed74 100644 --- a/app/simulation/solvers/algebraic.py +++ b/app/simulation/solvers/algebraic.py @@ -241,8 +241,9 @@ class ResistancePnl0001SeriesBinding: resistance_port: str resistance_other_port: str positive_flow_port: str - pipe: AmesimPnl0001 + pipe: AmesimPnl00r | AmesimPnl0001 pipe_port: str + pipe_other_port: str | None @dataclass(frozen=True) @@ -2338,24 +2339,31 @@ class PressureFlowSolver: ) -> 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, AmesimPnl0001 + 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, AmesimPnl0001 + first, pipe_types ): resistance, resistance_port = second, second_endpoint.port pipe, pipe_port = first, first_endpoint.port else: continue - if isinstance(pipe, AmesimPnl0002) or pipe_port != "port_1": - 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: @@ -2374,12 +2382,13 @@ class PressureFlowSolver: 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 PNL0001 R port.""" + """Causalize pressure between an orifice/valve and a PNL pipe.""" from scipy.optimize import brentq @@ -2390,7 +2399,14 @@ class PressureFlowSolver: pipe_port = binding.pipe_port pressure_a = resistance.get_port(binding.resistance_other_port).p - pressure_b = pipe.properties().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) @@ -2405,11 +2421,25 @@ class PressureFlowSolver: pressure_a, intermediate_pressure, ) - pipe_flow_into_connection = pipe.mass_flow( - intermediate_pressure, - pressure_b, - pipe.properties().T, - ) + 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) diff --git a/docs/other/amesim-component-migration-matrix.md b/docs/other/amesim-component-migration-matrix.md index 440211b..d29e4cd 100644 --- a/docs/other/amesim-component-migration-matrix.md +++ b/docs/other/amesim-component-migration-matrix.md @@ -38,7 +38,7 @@ | `PNVO001` | 8 | 信号调制气动孔口 | 第一版公开 | `amesim_pnvo001`,`flow` | 已接入名为 `res` 的标量信号输入端口,可由 `amesim_step0` 驱动开度;精确事件语义和 AMESim baseline 仍留后续修模。固定开度变体 `amesim_pnvo001_fixed` 继续保留。 | | `PN3NODE2` | 8 | 三端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_pn3node2`,`junctions` | 已接入三端等压、流量守恒基础版;AMESim port 2 参考温压语义和 stream 混合仍需单独测试。 | | `P4NODE2` | 8 | 四端气动节点,压力温度由 port 2 固定 | 第一版公开 | `amesim_p4node2`,`junctions` | 已接入四端等压、流量守恒基础版;仍需复核 port 2 参考温压和多支路混合。 | -| `PNL00R` | 4 | 管路纯阻性摩擦段 | 第一版公开 | `amesim_pnl00r`,`flow` | 已接入准稳态阻性管公开契约;仍需按 AMESim `PNL00R` 参数和摩擦公式复核。 | +| `PNL00R` | 4 | 管路纯阻性摩擦段 | 第一版公开 | `amesim_pnl00r`,`flow` | 已按 AMESim `pn2pipefr` 接入可压缩 `Cm/Cq` 关系、同侧上游温度和裸 `Cm` 诊断;使用未修改 AMESim 结果包直接保存的 `Re/ff` 标定 Re≈1400–3400 的 shifted-Hill C2 过渡曲线,并通过固定点、单调性和第八路 `0–0.81 s` 回归。 | | `PNL0001` | 20 | C-R 动态管路 | 第一版公开 | `amesim_pnl0001`,`flow` | 已按公开契约接入两状态管内容积 + port 1 摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 | | `PNL0002` | 8 | R-C-R 动态管路 | 第一版公开 | `amesim_pnl0002`,`flow` | 已按公开契约接入中心两状态容积 + 两端半长摩擦残差 + mode 2 换热项;仍需后续按 AMESim baseline 复核 `pn2pipefr_` 和 mode 1 多方语义。 | | `PNL0003` | 8 | C-R-C 动态管路 | 第一版公开 | `amesim_pnl0003`,`flow` | 已按公开契约接入两端四状态容积 + 中心摩擦流 + mode 2 换热项;大压差动态闭合和 AMESim baseline 误差仍留后续修模。 | @@ -89,6 +89,6 @@ ## 下一步执行建议 1. 以 `app.simulation.components.amesim.library` 的 22 个模型为公开清单唯一来源。 -2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2 / PNL00R` 与动态管路的 AMESim baseline 误差。 +2. 优先校准 `PNCH023 / PNOR001 / PN3NODE2 / P4NODE2` 与动态管路的 AMESim baseline 误差;`PNL00R` 保持现有层流、过渡区固定点和单调性回归。 3. 每次调整模型都同步补充目录校验、参数边界、System XML 编译和最小仿真测试。 4. 用完整或代表性的 `test_mql` 画布校准 `PNRP17 + PNCH012` 的压力、力、位移和容积轨迹,并补齐事件边界语义。 diff --git a/tests/baselines/simulation/test_mql_8/manifest.json b/tests/baselines/simulation/test_mql_8/manifest.json index c79bb22..cc317a0 100644 --- a/tests/baselines/simulation/test_mql_8/manifest.json +++ b/tests/baselines/simulation/test_mql_8/manifest.json @@ -1,23 +1,23 @@ { "schemaVersion": 1, - "id": "test_mql_8-progressive-v2", - "description": "Primary generic-solver regression target for the renamed 0.01 s-grid authoritative inputs, with a short smoke gate before staged extension.", + "id": "test_mql_8-progressive-v3", + "description": "Primary generic-solver regression target for the restored eight-branch 0.005 s-grid authoritative inputs, with a short smoke gate before staged extension.", "source": { "path": "tests/data/test-mql-8.xml", - "sha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3", - "bytes": 101013, + "sha256": "6eb2753a15af66fd8e91c169c4587e66b0ace2164c08c2cb23a67aab5fd09164", + "bytes": 104109, "xmlIsAuthoritative": true, "companionProject": { "path": "tests/data/test-mql-8.json", - "sha256": "258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa", - "bytes": 264675, + "sha256": "8d7a263691a9f09f39c92aba19ae1350911dc66b4904e98bae5f20592c5a3fcd", + "bytes": 272591, "executionInput": false }, "simulation": { "tStart": 0.0, - "tStop": 0.2, - "sampleStep": 0.01, - "maxStep": 0.01, + "tStop": 5.0, + "sampleStep": 0.005, + "maxStep": 0.005, "method": "BDF" } }, @@ -37,22 +37,38 @@ "status": "historicalOnly", "compatibleWithCurrentSource": false, "reason": "This deferred-stage decision was calculated from the former 0.002 s source report and is not a runtime prediction for the renamed v2 source." + }, + { + "path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-0.2.json", + "sha256": "93baac975413703bebe940308ccd0de5dbd753f7c1c4fe015907315f112618a5", + "sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3", + "status": "historicalOnly", + "compatibleWithCurrentSource": false, + "reason": "This accepted 0.2 s report predates the four restored PNL0001 branches and cannot validate the v3 source." + }, + { + "path": "tests/baselines/simulation/test_mql_8/runs/2026-08-17-production-v2-extension-decision.json", + "sha256": "c2dd8acfaae71e9c5f2eaef35d529dd1ce1d420b48703faff3d111678496b671", + "sourceXmlSha256": "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3", + "status": "historicalOnly", + "compatibleWithCurrentSource": false, + "reason": "This runtime extension decision was derived from the v2 source and is retained only as historical evidence." } ], "structure": { - "componentCount": 152, - "connectionCount": 174, - "dynamicComponentCount": 54, - "stateCount": 124, - "resultVariableCount": 1716, - "pressureFlowUnknownCount": 760, - "pressureFlowEquationCount": 760, + "componentCount": 156, + "connectionCount": 178, + "dynamicComponentCount": 58, + "stateCount": 132, + "resultVariableCount": 1784, + "pressureFlowUnknownCount": 776, + "pressureFlowEquationCount": 776, "pressureFlowIsSquare": true, - "logicalEffortCoordinateCount": 112, - "eliminatedEffortAliasCount": 320, - "canonicalCoordinateCount": 440, - "compatibilityScatterCount": 760, - "jacobianNonzeroCount": 3296, + "logicalEffortCoordinateCount": 116, + "eliminatedEffortAliasCount": 324, + "canonicalCoordinateCount": 452, + "compatibilityScatterCount": 776, + "jacobianNonzeroCount": 3280, "jacobianColorGroupCount": 52, "hasMechanicalStateEvents": true }, @@ -74,7 +90,7 @@ "productionEquivalentOutput": false }, "production": { - "description": "Acceptance lane: changes only tStop and preserves the source 0.01 s output and internal-step grid.", + "description": "Acceptance lane: changes only tStop and preserves the source 0.005 s output and internal-step grid.", "samplingMode": "source", "maxStepMode": "source", "instrumentationMode": "standard", @@ -105,13 +121,7 @@ "expectedSignalEventTimes": [ 0.04 ], - "expectedMechanicalTransitionTimes": [], - "goldens": { - "production": { - "path": "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json", - "sha256": "99fa7b3631a89f59f86551847175f3701a1567d8d0bd10d87545734f17515d72" - } - } + "expectedMechanicalTransitionTimes": [] }, "1s": { "stopTime": 1.0, @@ -178,7 +188,7 @@ "eventTimeAbsoluteToleranceSeconds": 0.00002, "signalEventTimeAbsoluteToleranceSeconds": 1e-12, "mechanicalTransitionTimesAvailable": true, - "note": "Physical state checkpoints/events and the value-free output-shape contract are separate. The production 0.2 s variant has an approved state golden; longer horizons and critical algebraic projections remain pending." + "note": "The restored v3 source currently enforces structure, finite series, residuals, checkpoints, and events. Its v2 state golden is historical-only; a new reviewed v3 state golden remains pending." }, "execution": { "causalExecutorV2Default": true, diff --git a/tests/test_amesim_pnl00r_component.py b/tests/test_amesim_pnl00r_component.py index c28f252..6360e38 100644 --- a/tests/test_amesim_pnl00r_component.py +++ b/tests/test_amesim_pnl00r_component.py @@ -2,8 +2,12 @@ from __future__ import annotations import unittest -from app.simulation.components.amesim.flow.pipes import AmesimPnl00r +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 @@ -94,12 +98,189 @@ class AmesimPnl00rComponentTests(unittest.TestCase): 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.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, + ), + ) - def test_pressure_drop_inversion_cache_keys_all_property_inputs(self) -> None: + 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, @@ -113,12 +294,12 @@ class AmesimPnl00rComponentTests(unittest.TestCase): "port_2": self.medium.specific_enthalpy(300.0), } ) - pipe._mass_flow_for_pressure_drop.cache_clear() + AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_clear() first = pipe.mass_flow(501000.0, 500000.0) - after_first = pipe._mass_flow_for_pressure_drop.cache_info() + after_first = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info() second = pipe.mass_flow(501000.0, 500000.0) - after_second = pipe._mass_flow_for_pressure_drop.cache_info() + after_second = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info() pipe.update_flow_temperature_references( { "port_1": self.medium.specific_enthalpy(400.0), @@ -126,7 +307,7 @@ class AmesimPnl00rComponentTests(unittest.TestCase): } ) third = pipe.mass_flow(501000.0, 500000.0) - after_temperature_change = pipe._mass_flow_for_pressure_drop.cache_info() + after_temperature_change = AmesimPnl0001._one_way_pn2pipefr_mass_flow.cache_info() self.assertEqual(first, second) self.assertEqual(after_first.misses, 1) @@ -345,6 +526,10 @@ class AmesimPnl00rComponentTests(unittest.TestCase): 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"], @@ -354,11 +539,11 @@ class AmesimPnl00rComponentTests(unittest.TestCase): results["cm"], abs(actual_flow) * upstream_temperature**0.5 - / (pipe.area * upstream_pressure), + / (flow_coefficient * pipe.area * upstream_pressure), ) self.assertAlmostEqual( results["ff"], - min(pipe.friction_factor(expected_reynolds), 64_000_000.0), + min(friction, 64_000_000.0), ) def test_friction_factor_transitions_from_laminar_to_turbulent(self) -> None: @@ -405,6 +590,69 @@ class AmesimPnl00rComponentTests(unittest.TestCase): 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", diff --git a/tests/test_benchmark_regression.py b/tests/test_benchmark_regression.py index 3ae5dcf..600e1fc 100644 --- a/tests/test_benchmark_regression.py +++ b/tests/test_benchmark_regression.py @@ -125,12 +125,12 @@ class RegressionManifestTests(unittest.TestCase): self.assertTrue(manifest["source"]["xmlIsAuthoritative"]) self.assertEqual( manifest["source"]["sha256"], - "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3", + "6eb2753a15af66fd8e91c169c4587e66b0ace2164c08c2cb23a67aab5fd09164", ) self.assertEqual(Path(manifest["_sourcePath"]).name, "test-mql-8.xml") self.assertEqual( manifest["source"]["companionProject"]["sha256"], - "258c50ee4850baa72fb7c2cc24536d0a631fc6a7f1fa6cedb7b6eea7c857cbaa", + "8d7a263691a9f09f39c92aba19ae1350911dc66b4904e98bae5f20592c5a3fcd", ) self.assertFalse( manifest["source"]["companionProject"]["executionInput"] @@ -142,6 +142,10 @@ class RegressionManifestTests(unittest.TestCase): 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"] + ) def test_in_memory_derivative_does_not_change_authoritative_xml(self) -> None: manifest = load_regression_manifest(DEFAULT_MANIFEST_PATH) @@ -155,9 +159,9 @@ class RegressionManifestTests(unittest.TestCase): max_step=0.005, ) - self.assertEqual(source_simulation_config(before)["tStop"], 0.2) - self.assertEqual(source_simulation_config(before)["sampleStep"], 0.01) - self.assertEqual(source_simulation_config(before)["maxStep"], 0.01) + self.assertEqual(source_simulation_config(before)["tStop"], 5.0) + self.assertEqual(source_simulation_config(before)["sampleStep"], 0.005) + self.assertEqual(source_simulation_config(before)["maxStep"], 0.005) 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) @@ -581,7 +585,7 @@ class ProgressiveSuiteTests(unittest.TestCase): self.assertEqual( seen, - [("solver-only", 0.02, 0.05), ("production", 0.01, 0.01)], + [("solver-only", 0.02, 0.05), ("production", 0.005, 0.005)], ) diff --git a/tests/test_causal_numeric_ir.py b/tests/test_causal_numeric_ir.py index e4b4daf..e300277 100644 --- a/tests/test_causal_numeric_ir.py +++ b/tests/test_causal_numeric_ir.py @@ -222,15 +222,15 @@ class TargetCausalNumericIRStructureTests(unittest.TestCase): assert self.compilation.ir is not None program = self.compilation.ir.program - self.assertEqual(len(program.compatibility_slots), 760) - self.assertEqual(len(program.canonical_slots), 440) - self.assertEqual(program.effort_group_count, 112) - self.assertEqual(program.flow_assignment_count, 328) - self.assertEqual(program.effort_scatter_count, 432) - self.assertEqual(program.eliminated_effort_replica_count, 320) + 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(440)), + tuple(range(452)), ) def test_target_batches_efforts_and_preserves_flow_stage_layout(self) -> None: @@ -240,8 +240,8 @@ class TargetCausalNumericIRStructureTests(unittest.TestCase): stage for stage in program.effort_stages if stage.variable == "p" ) - self.assertEqual(len(pressure_stage.operations), 72) - self.assertEqual(len(pressure_stage.evaluations), 36) + self.assertEqual(len(pressure_stage.operations), 76) + self.assertEqual(len(pressure_stage.evaluations), 40) self.assertTrue( all( evaluation.opcode @@ -251,7 +251,7 @@ class TargetCausalNumericIRStructureTests(unittest.TestCase): ) self.assertEqual( [len(stage.target_slots) for stage in program.flow_stages], - [110, 130, 49, 33, 5, 1], + [114, 134, 49, 33, 5, 1], ) def test_target_reference_execution_matches_all_compatibility_slots(self) -> None: diff --git a/tests/test_pressure_flow_solver_initialization.py b/tests/test_pressure_flow_solver_initialization.py index cfc5071..e78fea6 100644 --- a/tests/test_pressure_flow_solver_initialization.py +++ b/tests/test_pressure_flow_solver_initialization.py @@ -7,6 +7,7 @@ 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 @@ -110,6 +111,62 @@ class PressureFlowSolverInitializationTests(unittest.TestCase): 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) diff --git a/tests/test_supported_piston_tangent.py b/tests/test_supported_piston_tangent.py index 7ca55a6..dde02cc 100644 --- a/tests/test_supported_piston_tangent.py +++ b/tests/test_supported_piston_tangent.py @@ -165,9 +165,9 @@ class SupportedPistonTangentCompilerTests(unittest.TestCase): order=0, ) - self.assertEqual(pattern.nnz, 3296) - self.assertEqual(columns_only.nnz, 2464) - self.assertEqual(len(active_columns), 108) + 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( @@ -182,10 +182,10 @@ class SupportedPistonTangentCompilerTests(unittest.TestCase): self.assertEqual(diagnostics["originalColorGroupCount"], 52) self.assertEqual(diagnostics["remainingColorGroupCount"], 36) self.assertEqual(diagnostics["exactColumnCount"], 16) - self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 108) + self.assertEqual(diagnostics["finiteDifferenceColumnCount"], 116) self.assertEqual( jacobian._remaining_finite_difference_sparsity.nnz, - 2176, + 2160, ) def test_partial_unsupported_branch_fails_with_stable_reason(self) -> None: diff --git a/tests/test_test_mql_8_regression.py b/tests/test_test_mql_8_regression.py index ceec80a..4b8c5ad 100644 --- a/tests/test_test_mql_8_regression.py +++ b/tests/test_test_mql_8_regression.py @@ -127,12 +127,15 @@ class TestMql8StaticRegressionTests(unittest.TestCase): case_id, ) - def test_approved_production_golden_replays_its_source_report(self) -> None: + def test_historical_v2_golden_replays_its_source_report(self) -> None: repository_root = Path(self.manifest["_repositoryRoot"]) - reference = self.manifest["variants"]["0.2s"]["goldens"]["production"] + golden_path = ( + repository_root + / "tests/baselines/simulation/test_mql_8/goldens/production-0.2s-v1.json" + ) golden = load_regression_golden( - repository_root / reference["path"], - expected_sha256=reference["sha256"], + golden_path, + expected_sha256="99fa7b3631a89f59f86551847175f3701a1567d8d0bd10d87545734f17515d72", repository_root=repository_root, ) source_report = golden["provenance"]["sourceReport"] @@ -142,12 +145,16 @@ class TestMql8StaticRegressionTests(unittest.TestCase): 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_extension_decision_is_bound_to_the_current_report_and_budget(self) -> None: + 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( @@ -164,6 +171,10 @@ class TestMql8StaticRegressionTests(unittest.TestCase): first_decision = decision["decisions"][0] self.assertEqual( + decision["sourceXmlSha256"], + "170463d65d074da01f0f9e9dab730b3815c94c1cc80b5190ec2e3fe623da74d3", + ) + self.assertNotEqual( decision["sourceXmlSha256"], self.manifest["source"]["sha256"] ) self.assertEqual( @@ -191,7 +202,7 @@ class TestMql8StaticRegressionTests(unittest.TestCase): self.assertEqual(first_decision["outcome"], "deferred") self.assertTrue(decision["simulationWasNotStartedForDeferredCases"]) - def test_periodic_main_lane_exercises_the_approved_production_golden(self) -> None: + def test_periodic_main_lane_uses_the_current_production_source(self) -> None: workflow = ( Path(self.manifest["_repositoryRoot"]) / ".github/workflows/solver-regression.yml" @@ -199,11 +210,12 @@ class TestMql8StaticRegressionTests(unittest.TestCase): self.assertIn("default: production", workflow) self.assertIn("inputs.lane || 'production'", workflow) + self.assertNotIn("goldens", self.manifest["variants"]["0.2s"]) @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.", + f"Set {LONG_RUN_ENVIRONMENT}=1 to run bounded 0.005/0.2/1/5/10 s integration.", ) class TestMql8ProgressiveLongRegressionTests(unittest.TestCase): @classmethod