完善 PNL00R 摩擦模型与八路回归

This commit is contained in:
huojiarong committed 2026-08-18 09:30:29 +00:00
1 parent 684d28752a
commit f725f038b6
10 files changed
+498 -98

No files matched your search

+56 -17
View File
@@ -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,
+42 -12
View File
@@ -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)
@@ -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` 的压力、力、位移和容积轨迹,并补齐事件边界语义。
@@ -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,
+257 -9
View File
@@ -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",
+10 -6
View File
@@ -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)],
)
+10 -10
View File
@@ -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:
@@ -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)
+5 -5
View File
@@ -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:
+19 -7
View File
@@ -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