feat: integrate AMESim media models and editor UI

This commit is contained in:
ljz committed 2026-07-31 23:36:58 +08:00
1 parent 3e4f466114
commit e7177ab03e
181 files changed
+7042 -25270

No files matched your search

+23 -34
View File
@@ -16,8 +16,8 @@
- `components/experimental/junctions/`: 三通等连接节点。
- `components/amesim/`: AMESim 气动、信号和机械组件原语。
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑、基线运行入口,以及 test_mql 当前迁移过程中的系统装配和诊断脚本。
- `examples/test_mql/`: AMESim `test_mql` 的前端调用运行入口。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和基线运行入口。
- `examples/test_mql/`: AMESim `test_mql` 的系统装配、校准原语、诊断和运行入口。
- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
@@ -29,7 +29,8 @@
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
临时组件库的声明入口是 `components/experimental/library.py`,AMESim 第一版
公开临时库入口是 `components/amesim/library.py`。公开模型必须在
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
@@ -37,20 +38,24 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
当前关键文件:
- `core/medium.py`: 温度相关的理想气体近似介质 `IdealGasMedium`
- `core/peng_robinson.py`: `test_mql` 使用的氦气 Peng-Robinson 物性
- `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 状态方程
- `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/testmodel/test_mql.py`: `test_mql` 系统装配、132 状态总闭包和关键输出映射
- `examples/testmodel/test_mql_closure.py`: `test_mql` 气动网络 closure、snapshot、流量计算和端口写回
- `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/testmodel/run_test_mql_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
- `examples/testmodel/run_test_mql.py`: test_mql 基线运行与程序化执行入口
- `examples/test_mql/run_full_state_comparison.py`: `test_mql` 短时域 AMESim comparison 和诊断入口
- `examples/test_mql/run.py`: `test_mql` 结构运行与程序化执行入口
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
## 当前阶段进度
@@ -278,7 +283,7 @@ print(result.used_modelica_reference)
当前四个主变量的最大误差为:
- `mytank.p`: `max_abs_error = 134.960857 Pa`, `max_rel_error = 0.006798%`
- `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%`
@@ -287,31 +292,15 @@ print(result.used_modelica_reference)
## AMESim test_mql 当前进度
`test_mql` 是从 `AmesimModels/test_mql.ame` 新增迁移的 AMESim 模型,当前只在独立路径下推进,不修改旧 `testmodel`。新增命名保持 AMESim 原始别名和 `Data_Path`,方便后续逐变量对齐。
`test_mql` 从 `AmesimModels/test_mql.ame` 迁移,并与旧 `testmodel` 保持独立。
固定算例实现统一位于 `examples/test_mql/`,结果读取和比较能力位于
`reporting/`;公开拖拽组件由 `components/amesim/library.py` 单独登记。
当前已经完成:
- 解析 117 个组件、84 条 LINE 连接、直接组件接触、全局参数、仿真设置以及 AMESim 变量目录。
- 直接读取 `.ame` 包内 `test_mql_.var` 和 `test_mql_.results`;baseline 包含 1002 个时间点和 1116 个保存变量。
- 使用氦气 Peng-Robinson 物性,内部统一使用绝对压力,对外按 AMESim 表压和原始单位输出。
- 实现 `PNCH023 / PNCH012 / PNOR001 / PNVO001`,以及 `PNL0001 / PNL0002 / PNL0003 / PNL00R` 管路和 `PN3NODE2 / P4NODE2` 节点语义。
- 完成气动真实拓扑装配、canonical flow、端口写回、snapshot 和 112 状态气动 RHS。
- 实现 `PNRP17 / MECMAS21 / LSTP00A / LMECHN1 / UD00 / FORC` 当前工况可确认的机械行为,并形成 20 状态机械闭包。
- 将气动和机械部分组合成 132 状态总闭包,接入活塞体积反馈、气动力、外力、端止动和质量约束,可通过现有 solver 短时积分。
- 建立关键 `Data_Path` 序列导出、output schema、validation、AMESim 插值比较、误差排序、端点诊断和 PNCH012 RHS 项拆解。
当前确认的关键细节:
- `PNRP17` 活塞腔体积使用环形有效面积 `piston_area - rod_area`。
- `LSTP00A` 的 `gap` 观测单位是 mm,计算接触力前必须转换为 m。
- `PNCH023` 固定气室初始压力来自 `P0=153 bar` 的绝对压力;AMESim `press` 输出为相对 `101300 Pa` 的表压。
- `PNCH012` 变容腔初始压力对齐 AMESim 的 `1 bar` 绝对压力,`vol` 输出单位为 cm3,且末端体积等于基础死容积加对应活塞 `vol1`。
- `MECMAS21` 的 `x1dup / v1dup / acc1dup` 是第二机械端口观测,相对 `x1 / v1 / acc1` 为反号,不是重复同值。
- 本算例中 `MECMAS21` 的 `Fmin / Fmax / Fvisc / Ffric` 在 AMESim 结果里为零;当前只把这一工况能验证的部分写入测试,没有硬猜未激活碰撞/摩擦状态机。
当前默认 `0 -> 1e-5 s` comparison 已定位最大偏差为 `press@pn_c1_8`:初值对齐,但末值绝对误差约 `9.22849 Pa`。RHS 拆解显示边界体积功约 `0.026 W`,端口焓流约 `32722 W`,因此当前首要工作是比较 Python 的 `p4_port3_remote_chamber_to_line_flow` 与 AMESim 的 `dm1@pneumatic_69`,检查单位、符号、PNL0001 阻力和 `pnnode4_16` 节点平衡。
当前还不能宣称 `test_mql` 的 Python 时域仿真已经和 AMESim 全局一致。完整说明、运行命令和下一步校准路径见 `AmesimModels/test_mql/README.md`。
当前已形成 112 个气动状态和 20 个机械状态的总闭包、AMESim 原生结果读取、
`Data_Path` 输出校验及短时域 comparison。这里不再复制易过期的数值进度;
最新对比结果、运行命令、限制和下一步校准路径以
[`AmesimModels/test_mql/README.md`](../../AmesimModels/test_mql/README.md)
为唯一说明。当前仍不能宣称 Python 时域仿真与 AMESim 全局一致。
## Testmodel 当前架构判断
@@ -3,6 +3,10 @@ from __future__ import annotations
from collections.abc import Mapping
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, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
@@ -10,7 +14,7 @@ from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
@@ -29,6 +33,7 @@ class AmesimPnor001(AlgebraicComponent):
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cq",
0.72,
@@ -37,6 +42,7 @@ class AmesimPnor001(AlgebraicComponent):
unit="",
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
),
ParameterDefinition(
"area",
@@ -46,6 +52,7 @@ class AmesimPnor001(AlgebraicComponent):
unit="m2",
minimum=0.0,
maximum=1.0,
description="选择 Cq/面积方式时用于流量计算的有效孔口面积。",
),
ParameterDefinition(
"Cv",
@@ -54,6 +61,7 @@ class AmesimPnor001(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的英制流量系数。",
),
ParameterDefinition(
"Kv",
@@ -62,15 +70,7 @@ class AmesimPnor001(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="选择 Kv 方式时使用的公制流量系数。",
),
ParameterDefinition(
"flowset",
@@ -80,6 +80,7 @@ class AmesimPnor001(AlgebraicComponent):
unit="",
minimum=1.0,
maximum=3.0,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
)
RESULT_VARIABLES = (
@@ -115,7 +116,7 @@ class AmesimPnor001(AlgebraicComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cq: float = 0.72,
area: float = 5.0e-6,
@@ -140,7 +141,7 @@ class AmesimPnor001(AlgebraicComponent):
self.area = float(area)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
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.")
@@ -163,7 +164,7 @@ class AmesimPnor001(AlgebraicComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimPnor001:
return cls(
@@ -206,7 +207,7 @@ class AmesimPnor001(AlgebraicComponent):
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
return self.medium.T_ref
def mass_flow(self, p_1: float, p_2: float) -> float:
@@ -321,6 +322,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cq",
0.72,
@@ -329,6 +331,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=1.0e-10,
maximum=1.0,
description="孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。",
),
ParameterDefinition(
"area0",
@@ -338,6 +341,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="m2",
minimum=0.0,
maximum=1.0,
description="阀门完全开启时的最大有效孔口面积。",
),
ParameterDefinition(
"Cv",
@@ -346,6 +350,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
description="选择 Cv 方式时使用的最大英制流量系数。",
),
ParameterDefinition(
"Kv",
@@ -354,15 +359,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
quantity="dimensionless",
unit="",
minimum=0.0,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="选择 Kv 方式时使用的最大公制流量系数。",
),
ParameterDefinition(
"flowset",
@@ -372,6 +369,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=1.0,
maximum=3.0,
description="流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。",
),
ParameterDefinition(
"opening",
@@ -381,6 +379,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
unit="",
minimum=0.0,
maximum=1.0,
description="固定的归一化阀门开度;0 表示关闭,1 表示完全开启。",
),
)
RESULT_VARIABLES = (
@@ -424,7 +423,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cq: float = 0.72,
area0: float = 5.0e-6,
@@ -451,7 +450,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
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.")
@@ -475,7 +474,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimPnvo001FixedOpening:
return cls(
@@ -509,7 +508,7 @@ class AmesimPnvo001FixedOpening(AlgebraicComponent):
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
return self.medium.T_ref
def mass_flow(self, p_2: float, p_3: float) -> float:
@@ -621,11 +620,11 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
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),
ParameterDefinition("area0", 5.0e-6, label="最大孔口面积", quantity="area", unit="m2", minimum=0.0, maximum=1.0),
ParameterDefinition("Cv", 0.5, label="最大流量系数 Cv", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("Kv", 0.4, label="最大流量系数 Kv", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("gi", 1.0, label="气体类型索引", quantity="dimensionless", unit="", minimum=1.0, maximum=99.0),
ParameterDefinition("flowset", 1.0, label="流量系数设置", quantity="dimensionless", unit="", minimum=1.0, maximum=3.0),
ParameterDefinition("opening0", 1.0, label="初始开度", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
)
@@ -646,7 +645,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cq: float = 0.72,
area0: float = 5.0e-6,
@@ -673,7 +672,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
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.")
@@ -691,7 +690,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnvo001SignalOpening":
return cls(name=name, medium=medium, **dict(parameters))
+38 -51
View File
@@ -3,6 +3,10 @@ from __future__ import annotations
from collections.abc import Mapping
from math import isclose, log10, pi, sqrt
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, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
@@ -11,7 +15,7 @@ from app.simulation.core.metadata import (
ResultVariableDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
@@ -31,6 +35,7 @@ class AmesimPnl00r(AlgebraicComponent):
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"diam",
0.01,
@@ -39,6 +44,7 @@ class AmesimPnl00r(AlgebraicComponent):
unit="m",
minimum=0.0,
minimum_exclusive=True,
description="管路的有效内径,用于计算流通面积和摩擦压降。",
),
ParameterDefinition(
"le",
@@ -48,6 +54,7 @@ class AmesimPnl00r(AlgebraicComponent):
unit="m",
minimum=0.0,
minimum_exclusive=True,
description="参与摩擦压降计算的管路有效长度。",
),
ParameterDefinition(
"rr",
@@ -57,15 +64,7 @@ class AmesimPnl00r(AlgebraicComponent):
unit="",
minimum=0.0,
maximum=0.1,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。",
),
)
RESULT_VARIABLES = (
@@ -117,7 +116,7 @@ class AmesimPnl00r(AlgebraicComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
diam: float = 0.01,
le: float = 1.0,
@@ -130,7 +129,7 @@ class AmesimPnl00r(AlgebraicComponent):
self.diam = float(diam)
self.le = float(le)
self.rr = float(rr)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
self.area = pi * self.diam * self.diam / 4.0
initial_h = medium.specific_enthalpy(medium.T_ref)
@@ -151,7 +150,7 @@ class AmesimPnl00r(AlgebraicComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimPnl00r:
return cls(
@@ -166,22 +165,11 @@ class AmesimPnl00r(AlgebraicComponent):
def _port_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return max(self.medium.temperature_from_enthalpy(port.h_outflow), 1.0)
return self.medium.T_ref
@staticmethod
def _dynamic_viscosity(temperature_k: float) -> float:
if temperature_k <= 0.0:
raise ValueError("temperature_k must be positive")
reference_temperature = 293.15
reference_viscosity = 1.82e-5
sutherland_constant = 110.4
return (
reference_viscosity
* (temperature_k / reference_temperature) ** 1.5
* (reference_temperature + sutherland_constant)
/ (temperature_k + sutherland_constant)
)
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)
@@ -322,6 +310,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"diam",
0.01,
@@ -330,6 +319,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
unit="m",
minimum=0.0,
minimum_exclusive=True,
description="管路的有效内径,用于计算流通面积、储气容积和摩擦压降。",
),
ParameterDefinition(
"le",
@@ -339,6 +329,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
unit="m",
minimum=0.0,
minimum_exclusive=True,
description="管路的有效长度,用于计算储气容积、换热面积和摩擦压降。",
),
ParameterDefinition(
"rr",
@@ -348,6 +339,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
unit="",
minimum=0.0,
maximum=0.1,
description="管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。",
),
ParameterDefinition(
"k",
@@ -358,6 +350,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
minimum=0.0,
minimum_exclusive=True,
maximum=2.0,
description="AMESim 管路热力学配置使用的多方指数。",
),
ParameterDefinition(
"kth",
@@ -366,6 +359,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
description="管内气体与外部环境之间的对流换热系数。",
),
ParameterDefinition(
"extemp",
@@ -375,15 +369,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="管路外部环境的绝对温度,用于计算换热功率。",
),
ParameterDefinition(
"mode",
@@ -393,6 +379,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
unit="",
minimum=1.0,
maximum=2.0,
description="热模型选择:1 为绝热,2 为按换热系数计算环境换热。",
),
ParameterDefinition(
"p0",
@@ -402,6 +389,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时管内气体的绝对压力。",
),
ParameterDefinition(
"T0",
@@ -411,6 +399,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时管内气体的绝对温度。",
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (
@@ -462,7 +451,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
diam: float = 0.01,
le: float = 1.0,
@@ -497,14 +486,14 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
self.k = float(k)
self.kth = float(kth)
self.extemp = float(extemp)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
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 = self.p0 * self.volume / (medium.R_gas * self.T0)
m0 = medium.density(self.p0, self.T0) * self.volume
U0 = m0 * medium.specific_internal_energy(self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
@@ -527,7 +516,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnl0001":
return cls(
@@ -566,9 +555,8 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
return 0.0
return self.kth * self.exchange_area * (self.extemp - temperature)
@staticmethod
def _dynamic_viscosity(temperature_k: float) -> float:
return AmesimPnl00r._dynamic_viscosity(temperature_k)
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)
@@ -745,7 +733,7 @@ class AmesimPnl0002(AmesimPnl0001):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnl0002":
return cls(
@@ -943,7 +931,7 @@ class AmesimPnl0003(DynamicComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
diam: float = 0.01,
le: float = 1.0,
@@ -982,7 +970,7 @@ class AmesimPnl0003(DynamicComponent):
self.k = float(k)
self.kth = float(kth)
self.extemp = float(extemp)
self.gi = AmesimPnl0001._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
self.mode = AmesimPnl0001._integer_parameter("mode", mode)
self.area = pi * self.diam * self.diam / 4.0
self.volume = self.area * self.le
@@ -1004,13 +992,13 @@ class AmesimPnl0003(DynamicComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
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 = pressure * self.compliance_volume / (self.medium.R_gas * temperature)
mass = self.medium.density(pressure, temperature) * self.compliance_volume
return VolumeState(m=mass, U=mass * self.medium.specific_internal_energy(temperature))
def get_state_vector(self) -> list[float]:
@@ -1040,9 +1028,8 @@ class AmesimPnl0003(DynamicComponent):
def refresh_thermodynamic_ports(self) -> tuple[ThermodynamicProperties, ThermodynamicProperties]:
return self.properties_1(), self.properties_2()
@staticmethod
def _dynamic_viscosity(temperature_k: float) -> float:
return AmesimPnl00r._dynamic_viscosity(temperature_k)
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)
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from math import isclose, isfinite
from types import MappingProxyType
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import GasMedium
AMESIM_BUILTIN_AIR_GAS_INDEX = 0
AMESIM_DEFAULT_GAS_INDEX = AMESIM_BUILTIN_AIR_GAS_INDEX
AMESIM_MIN_GAS_INDEX = AMESIM_BUILTIN_AIR_GAS_INDEX
AMESIM_MIN_DEFINED_GAS_INDEX = 1
AMESIM_MAX_GAS_INDEX = 99
AMESIM_GAS_INDEX_PARAMETER = ParameterDefinition(
"gi",
float(AMESIM_DEFAULT_GAS_INDEX),
label="介质物性模型(gi)",
quantity="dimensionless",
unit="",
minimum=float(AMESIM_MIN_GAS_INDEX),
maximum=float(AMESIM_MAX_GAS_INDEX),
editor="amesimGasReference",
description=(
"选择本元件使用的气体介质定义索引;0 表示内置空气,"
"1–99 引用画布中的介质定义组件。"
),
)
AMESIM_GAS_DEFINITION_INDEX_PARAMETER = ParameterDefinition(
"gi",
float(AMESIM_MIN_DEFINED_GAS_INDEX),
label="介质定义索引(gi)",
quantity="dimensionless",
unit="",
minimum=float(AMESIM_MIN_DEFINED_GAS_INDEX),
maximum=float(AMESIM_MAX_GAS_INDEX),
description=(
"介质定义在当前模型中的唯一索引;由画布自动分配,"
"0 保留给内置空气。"
),
)
def normalize_amesim_gas_index(value: float | int) -> int:
"""Validate an AMESim gas reference.
Index 0 is reserved for the built-in ideal-gas air profile. Positive
indices refer to medium-definition components placed in the project.
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError("AMESim gas type index gi must be a number.")
numeric = float(value)
if not isfinite(numeric):
raise ValueError("AMESim gas type index gi must be finite.")
rounded = round(numeric)
if not isclose(numeric, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError("AMESim gas type index gi must be an integer value.")
index = int(rounded)
if not AMESIM_MIN_GAS_INDEX <= index <= AMESIM_MAX_GAS_INDEX:
raise ValueError(
"AMESim gas type index gi must be between "
f"{AMESIM_MIN_GAS_INDEX} and {AMESIM_MAX_GAS_INDEX}."
)
return index
def normalize_amesim_defined_gas_index(value: float | int) -> int:
"""Validate a positive index owned by a project medium definition."""
index = normalize_amesim_gas_index(value)
if index < AMESIM_MIN_DEFINED_GAS_INDEX:
raise ValueError(
"AMESim medium definition index gi must be between "
f"{AMESIM_MIN_DEFINED_GAS_INDEX} and {AMESIM_MAX_GAS_INDEX}; "
"gi=0 is reserved for built-in ideal-gas air."
)
return index
@dataclass(frozen=True)
class AmesimGasDefinition:
"""One AMESim PNGD-style gas-definition slot.
``fluid_type`` and ``eos_type`` are intentionally optional today. They
reserve the metadata needed to map a future PNGD00 helium definition while
the executable behavior is supplied by ``medium``.
"""
gi: int
label: str
medium: GasMedium
fluid_type: int | None = None
eos_type: int | None = None
def __post_init__(self) -> None:
normalized = normalize_amesim_gas_index(self.gi)
object.__setattr__(self, "gi", normalized)
if not self.label.strip():
raise ValueError("AMESim gas definition label must not be empty.")
class AmesimGasRegistry:
"""Resolve AMESim component ``gi`` references to thermodynamic media."""
def __init__(
self,
definitions: Iterable[AmesimGasDefinition] = (),
*,
default_gi: int = AMESIM_DEFAULT_GAS_INDEX,
) -> None:
from app.simulation.components.amesim.media.mediums import (
AmesimIdealAirMedium,
)
self.default_gi = normalize_amesim_gas_index(default_gi)
self._definitions: dict[int, AmesimGasDefinition] = {
AMESIM_BUILTIN_AIR_GAS_INDEX: AmesimGasDefinition(
gi=AMESIM_BUILTIN_AIR_GAS_INDEX,
label="空气(理想气体,内置默认)",
medium=AmesimIdealAirMedium(),
)
}
for definition in definitions:
self.register(definition)
@property
def definitions(self) -> Mapping[int, AmesimGasDefinition]:
return MappingProxyType(self._definitions)
def register(self, definition: AmesimGasDefinition) -> None:
if not isinstance(definition, AmesimGasDefinition):
raise TypeError("AMESim gas registry entries must use AmesimGasDefinition.")
if definition.gi == AMESIM_BUILTIN_AIR_GAS_INDEX:
raise ValueError(
"AMESim gas type index gi=0 is reserved for built-in "
"ideal-gas air and cannot be replaced."
)
if definition.gi in self._definitions:
raise ValueError(
f"AMESim gas type index gi={definition.gi} is already defined."
)
self._definitions[definition.gi] = definition
def copy(self) -> AmesimGasRegistry:
"""Return an independent registry for one project compilation."""
copied = AmesimGasRegistry(
(
definition
for index, definition in self._definitions.items()
if index != AMESIM_BUILTIN_AIR_GAS_INDEX
),
default_gi=self.default_gi,
)
copied._definitions[AMESIM_BUILTIN_AIR_GAS_INDEX] = self._definitions[
AMESIM_BUILTIN_AIR_GAS_INDEX
]
return copied
def resolve(
self,
gi: float | int,
*,
component_name: str | None = None,
) -> GasMedium:
index = normalize_amesim_gas_index(gi)
try:
return self._definitions[index].medium
except KeyError as exc:
owner = f" for component '{component_name}'" if component_name else ""
available = ", ".join(str(index) for index in sorted(self._definitions))
available_message = available or "none"
raise ValueError(
f"AMESim gas type index gi={index}{owner} is not defined. "
"Register a PNGD-style gas definition before using this index. "
f"Available indices: {available_message}."
) from exc
@property
def default_medium(self) -> GasMedium:
return self.resolve(self.default_gi)
def resolve_network_media(
self,
component_gas_indices: Mapping[str, float | int | None],
pneumatic_connections: Iterable[tuple[str, str]],
) -> dict[str, GasMedium]:
"""Assign one medium to every connected pneumatic circuit.
Components without ``gi`` inherit the explicit index used by their
circuit. Conflicting indices inside one circuit are rejected instead
of silently mixing different gases.
"""
parents = {component_id: component_id for component_id in component_gas_indices}
def find(component_id: str) -> str:
parent = parents[component_id]
while parent != parents[parent]:
parent = parents[parent]
while component_id != parent:
next_component = parents[component_id]
parents[component_id] = parent
component_id = next_component
return parent
def union(left: str, right: str) -> None:
left_root = find(left)
right_root = find(right)
if left_root != right_root:
parents[right_root] = left_root
for source, target in pneumatic_connections:
if source in parents and target in parents:
union(source, target)
members_by_root: dict[str, list[str]] = {}
for component_id in component_gas_indices:
members_by_root.setdefault(find(component_id), []).append(component_id)
media: dict[str, GasMedium] = {}
for members in members_by_root.values():
indexed_components: dict[int, list[str]] = {}
for component_id in members:
raw_index = component_gas_indices[component_id]
if raw_index is None:
continue
index = normalize_amesim_gas_index(raw_index)
indexed_components.setdefault(index, []).append(component_id)
if len(indexed_components) > 1:
details = ", ".join(
f"gi={index} ({', '.join(sorted(component_ids))})"
for index, component_ids in sorted(indexed_components.items())
)
raise ValueError(
"Connected pneumatic circuit contains conflicting AMESim "
f"gas definitions: {details}."
)
index = (
next(iter(indexed_components))
if indexed_components
else self.default_gi
)
indexed_members = indexed_components.get(index, members)
medium = self.resolve(
index,
component_name=", ".join(sorted(indexed_members)),
)
for component_id in members:
media[component_id] = medium
return media
def default_amesim_gas_registry() -> AmesimGasRegistry:
"""Create a registry containing only built-in gi=0 ideal-gas air."""
return AmesimGasRegistry()
+4 -1
View File
@@ -9,11 +9,12 @@ from app.simulation.core.catalog import (
LIBRARY = ComponentLibrarySpec(
id="amesim",
label="AMESim 组件库",
version="0.1.0",
version="0.2.0",
source_package="app.simulation.components.amesim",
temporary=True,
order=200,
categories=(
ComponentCategorySpec(id="media", label="介质物性", order=5),
ComponentCategorySpec(id="storage", label="储能元件", order=10),
ComponentCategorySpec(id="flow", label="流动元件", order=20),
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
@@ -22,6 +23,8 @@ LIBRARY = ComponentLibrarySpec(
ComponentCategorySpec(id="mechanical", label="机械元件", order=60),
),
models=(
"app.simulation.components.amesim.media.properties:AmesimIdealAirMediumDefinition",
"app.simulation.components.amesim.media.properties:AmesimHeliumMediumDefinition",
"app.simulation.components.amesim.boundary.sources:AmesimPnpl01",
"app.simulation.components.amesim.signals.sources:AmesimStep0",
"app.simulation.components.amesim.signals.sources:AmesimUd00",
@@ -0,0 +1,33 @@
"""AMESim medium-property definition components."""
from app.simulation.components.amesim.media.mediums import (
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
AMESIM_AIR_PROPERTY_MODELS,
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
AMESIM_HELIUM_PROPERTY_MODELS,
AmesimGasPropertyModelSpec,
AmesimHeliumPengRobinsonMedium,
AmesimIdealAirMedium,
)
from app.simulation.components.amesim.media.properties import (
AMESIM_AIR_PROPERTY_MODEL_PARAMETER,
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER,
AmesimGasMediumDefinitionComponent,
AmesimHeliumMediumDefinition,
AmesimIdealAirMediumDefinition,
)
__all__ = (
"AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL",
"AMESIM_AIR_PROPERTY_MODELS",
"AMESIM_AIR_PROPERTY_MODEL_PARAMETER",
"AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL",
"AMESIM_HELIUM_PROPERTY_MODELS",
"AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER",
"AmesimGasMediumDefinitionComponent",
"AmesimGasPropertyModelSpec",
"AmesimHeliumMediumDefinition",
"AmesimHeliumPengRobinsonMedium",
"AmesimIdealAirMedium",
"AmesimIdealAirMediumDefinition",
)
@@ -0,0 +1,109 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import ClassVar
from app.simulation.core.medium import GasMedium, IdealGasMedium
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
@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"
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_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 first public AMESim port keeps the committed
constant-heat-capacity caloric model so it can be consumed through the
same :class:`GasMedium` contract as ideal-gas air.
"""
SUBSTANCE_ID: ClassVar[str] = "helium"
PROPERTY_METHOD_ID: ClassVar[str] = "peng_robinson"
fluid: ClassVar[PengRobinsonFluid] = HELIUM_PR
name: str = "AMESimHeliumPengRobinson"
R_gas: float = HELIUM_PR.specific_gas_constant
cp_ref: float = 5193.0
T_ref: float = 293.15
cp_slope: float = 0.0
viscosity_ref: float = 1.96e-5
viscosity_T_ref: float = 293.15
sutherland_constant: float = 79.4
@property
def cv(self) -> float:
return 3116.0
def cv_at_temperature(self, T: float) -> float:
del T
return self.cv
def density(self, p: float, T: float) -> float:
return self.fluid.density(p, T)
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)
@dataclass(frozen=True)
class AmesimGasPropertyModelSpec:
"""A selectable calculation method for one AMESim gas substance."""
value: int
label: str
method_id: str
factory: Callable[[], GasMedium]
eos_type: int
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_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,
),
)
@@ -0,0 +1,196 @@
from __future__ import annotations
from abc import ABC
from collections.abc import Mapping
from app.simulation.components.amesim.gases import (
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
AmesimGasDefinition,
normalize_amesim_defined_gas_index,
)
from app.simulation.components.amesim.media.mediums import (
AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL,
AMESIM_AIR_PROPERTY_MODELS,
AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL,
AMESIM_HELIUM_PROPERTY_MODELS,
AmesimGasPropertyModelSpec,
)
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec
from app.simulation.core.medium import GasMedium
from app.simulation.core.metadata import ParameterDefinition, ParameterOption
AMESIM_AIR_PROPERTY_MODEL_PARAMETER = ParameterDefinition(
"property_model",
float(AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL),
label="物性计算模型",
quantity="dimensionless",
unit="",
minimum=float(min(model.value for model in AMESIM_AIR_PROPERTY_MODELS)),
maximum=float(max(model.value for model in AMESIM_AIR_PROPERTY_MODELS)),
editor="amesimGasPropertyModel",
options=tuple(
ParameterOption(value=model.value, label=model.label)
for model in AMESIM_AIR_PROPERTY_MODELS
),
description="选择空气介质的物性计算方法;当前首版提供理想气体模型。",
)
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER = ParameterDefinition(
"property_model",
float(AMESIM_HELIUM_PENG_ROBINSON_PROPERTY_MODEL),
label="物性计算模型",
quantity="dimensionless",
unit="",
minimum=float(min(model.value for model in AMESIM_HELIUM_PROPERTY_MODELS)),
maximum=float(max(model.value for model in AMESIM_HELIUM_PROPERTY_MODELS)),
editor="amesimGasPropertyModel",
options=tuple(
ParameterOption(value=model.value, label=model.label)
for model in AMESIM_HELIUM_PROPERTY_MODELS
),
description=(
"选择氦气介质的物性计算方法;当前首版提供 "
"Peng–Robinson 状态方程模型。"
),
)
class AmesimGasMediumDefinitionComponent(AlgebraicComponent, ABC):
"""Compile-time definition of one project-scoped AMESim gas medium.
Concrete subclasses declare one substance and its available calculation
methods; each instance selects a method through ``property_model``. They
deliberately expose no physical ports or equations: the compiler consumes
them before it creates the simulation network.
"""
IS_AMESIM_GAS_MEDIUM_DEFINITION = True
MEDIUM_LABEL = ""
FLUID_TYPE: int | None = None
PROPERTY_MODELS: tuple[AmesimGasPropertyModelSpec, ...] = ()
def __init__(
self,
name: str,
gi: float,
property_model: float = float(AMESIM_AIR_IDEAL_GAS_PROPERTY_MODEL),
) -> None:
super().__init__(name)
self.gi = normalize_amesim_defined_gas_index(gi)
self.property_model = self._resolve_property_model(property_model).value
self.set_parameter_values(
{
"gi": self.gi,
"property_model": self.property_model,
}
)
def _resolve_property_model(
self,
value: float | int,
) -> AmesimGasPropertyModelSpec:
for model in self.PROPERTY_MODELS:
if float(model.value) == float(value):
return model
available = ", ".join(str(model.value) for model in self.PROPERTY_MODELS)
raise ValueError(
f"AMESim medium definition '{self.name}' does not support property "
f"model {value:g}; available models: {available or 'none'}."
)
def build_medium(self) -> GasMedium:
"""Create the executable property model selected by this instance."""
return self._resolve_property_model(self.property_model).build_medium()
def gas_definition(self) -> AmesimGasDefinition:
model = self._resolve_property_model(self.property_model)
return AmesimGasDefinition(
gi=self.gi,
label=f"{self.MEDIUM_LABEL}({model.label})",
medium=self.build_medium(),
fluid_type=self.FLUID_TYPE,
eos_type=model.eos_type,
)
class AmesimIdealAirMediumDefinition(AmesimGasMediumDefinitionComponent):
"""Project gas slot using the built-in ideal-gas air property method."""
MODEL_TYPE = "amesim_ideal_air_medium"
MODEL_VERSION = "0.2.0"
PORTS = ()
PARAMETERS = (
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
AMESIM_AIR_PROPERTY_MODEL_PARAMETER,
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="空气介质定义",
library_id="amesim",
category_id="media",
symbol="amesim_ideal_air_medium",
ports=(),
order=10,
role="amesimGasMediumDefinition",
)
MEDIUM_LABEL = "空气"
FLUID_TYPE = 2
PROPERTY_MODELS = AMESIM_AIR_PROPERTY_MODELS
@classmethod
def create(
cls,
*,
name: str,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimIdealAirMediumDefinition:
del medium
return cls(
name=name,
gi=parameters["gi"],
property_model=parameters["property_model"],
)
class AmesimHeliumMediumDefinition(AmesimGasMediumDefinitionComponent):
"""Project gas slot using the AMESim helium Peng-Robinson method."""
MODEL_TYPE = "amesim_helium_medium"
MODEL_VERSION = "0.1.0"
PORTS = ()
PARAMETERS = (
AMESIM_GAS_DEFINITION_INDEX_PARAMETER,
AMESIM_HELIUM_PROPERTY_MODEL_PARAMETER,
)
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="氦气介质定义",
library_id="amesim",
category_id="media",
symbol="amesim_helium_medium",
ports=(),
order=20,
role="amesimGasMediumDefinition",
)
MEDIUM_LABEL = "氦气"
FLUID_TYPE = 12
PROPERTY_MODELS = AMESIM_HELIUM_PROPERTY_MODELS
@classmethod
def create(
cls,
*,
name: str,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimHeliumMediumDefinition:
del medium
return cls(
name=name,
gi=parameters["gi"],
property_model=parameters["property_model"],
)
@@ -1,8 +1,11 @@
from __future__ import annotations
from collections.abc import Mapping
from math import isclose
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
@@ -11,7 +14,7 @@ from app.simulation.core.metadata import (
ResultVariableDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.medium import GasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
@@ -32,6 +35,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cvol",
0.057,
@@ -40,6 +44,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="m3",
minimum=0.0,
minimum_exclusive=True,
description="气室内部用于储存气体的固定有效容积。",
),
ParameterDefinition(
"kth",
@@ -48,6 +53,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
),
ParameterDefinition(
"sth",
@@ -56,6 +62,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
quantity="area",
unit="m2",
minimum=0.0,
description="气室与环境进行热交换的有效表面积。",
),
ParameterDefinition(
"extemp",
@@ -65,15 +72,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
),
ParameterDefinition(
"p0",
@@ -83,6 +82,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时气室内气体的绝对压力。",
),
ParameterDefinition(
"T0",
@@ -92,6 +92,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时气室内气体的绝对温度。",
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
@@ -110,7 +111,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cvol: float = 0.057,
kth: float = 0.0,
@@ -137,10 +138,10 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = self._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
self.p0 = float(p0)
self.T0 = float(T0)
m0 = self.p0 * self.cvol / (medium.R_gas * self.T0)
m0 = medium.density(self.p0, self.T0) * self.cvol
U0 = m0 * medium.specific_internal_energy(self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
@@ -151,19 +152,12 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
self.port_2.p = self.p0
self.port_2.h_outflow = initial_h
@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"PNCH023 parameter {name} must be an integer value.")
return int(rounded)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> AmesimPnch023:
return cls(
@@ -270,6 +264,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
)
PARAMETERS = (
AMESIM_GAS_INDEX_PARAMETER,
ParameterDefinition(
"cvol0",
0.015,
@@ -278,6 +273,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
unit="m3",
minimum=0.0,
minimum_exclusive=True,
description="变容气室在所有外部容积为零时仍保留的基础容积。",
),
ParameterDefinition(
"kth",
@@ -286,6 +282,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
description="气室与环境之间的对流换热系数,与换热面积共同决定换热功率。",
),
ParameterDefinition(
"sth",
@@ -294,6 +291,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
quantity="area",
unit="m2",
minimum=0.0,
description="气室与环境进行热交换的有效表面积。",
),
ParameterDefinition(
"extemp",
@@ -303,15 +301,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
description="气室外部环境的绝对温度,用于计算气体与环境之间的换热。",
),
ParameterDefinition(
"p0",
@@ -321,6 +311,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
description="仿真开始时气室内气体的绝对压力。",
),
ParameterDefinition(
"T0",
@@ -330,6 +321,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
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"),
@@ -361,7 +353,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
def __init__(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
*,
cvol0: float = 0.015,
kth: float = 0.0,
@@ -404,7 +396,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = AmesimPnch023._integer_parameter("gi", gi)
self.gi = normalize_amesim_gas_index(gi)
self.p0 = float(p0)
self.T0 = float(T0)
self.external_volumes = {
@@ -421,7 +413,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
}
if self.total_volume() <= 0.0:
raise ValueError("PNCH012 total volume must be positive.")
m0 = self.p0 * self.total_volume() / (medium.R_gas * self.T0)
m0 = medium.density(self.p0, self.T0) * self.total_volume()
U0 = m0 * medium.specific_internal_energy(self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
@@ -436,7 +428,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnch012":
return cls(name=name, medium=medium, **dict(parameters))
+2 -2
View File
@@ -15,7 +15,7 @@ from app.simulation.core.metadata import (
from app.simulation.core.ports import PortDefinition, PortState
if TYPE_CHECKING:
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.medium import GasMedium
class Component(ABC):
@@ -185,7 +185,7 @@ class Component(ABC):
cls,
*,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
parameters: Mapping[str, float],
) -> Component:
"""Create a catalog model from normalized SI parameters."""
+2
View File
@@ -5,6 +5,7 @@ from typing import Literal
PortDisplaySide = Literal["left", "right"]
ComponentCatalogRole = Literal["amesimGasMediumDefinition"]
@dataclass(frozen=True)
@@ -42,6 +43,7 @@ class ComponentDisplaySpec:
symbol: str
ports: tuple[PortDisplaySpec, ...]
order: int = 0
role: ComponentCatalogRole | None = None
@property
def port_by_name(self) -> dict[str, PortDisplaySpec]:
+79
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
@@ -12,6 +13,53 @@ class ThermodynamicProperties:
h: float
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 dynamic_viscosity(self, T: float) -> float: ...
def specific_internal_energy(self, T: float) -> float: ...
def specific_enthalpy(self, T: float) -> float: ...
def temperature_from_internal_energy(self, u: float) -> float: ...
def temperature_from_enthalpy(self, 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: ...
@dataclass(frozen=True)
class IdealGasMedium:
"""Temperature-dependent ideal-gas air approximation.
@@ -27,6 +75,9 @@ class IdealGasMedium:
cp_ref: float = 1005.0
T_ref: float = 300.0
cp_slope: float = 0.0
viscosity_ref: float = 1.82e-5
viscosity_T_ref: float = 293.15
sutherland_constant: float = 110.4
@property
def cv(self) -> float:
@@ -45,6 +96,18 @@ class IdealGasMedium:
def density(self, p: float, T: float) -> float:
return p / (self.R_gas * T)
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 specific_internal_energy(self, T: float) -> float:
delta_T = T - self.T_ref
return (
@@ -77,6 +140,22 @@ class IdealGasMedium:
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
def temperature_from_mass_internal_energy(self, m: float, U: float) -> float:
if m <= 0.0:
raise ValueError("Mass must stay positive when recovering temperature.")
+34
View File
@@ -6,6 +6,10 @@ from typing import Literal
ResultVariableScope = Literal["component", "port"]
ParameterEditor = Literal[
"amesimGasReference",
"amesimGasPropertyModel",
]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
@@ -34,6 +38,20 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = {
}
@dataclass(frozen=True)
class ParameterOption:
"""One numeric choice exposed by a catalog-backed parameter editor."""
value: float
label: str
def as_interface_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
}
@dataclass(frozen=True)
class ParameterDefinition:
"""User-configurable model input expressed in the backend SI contract."""
@@ -46,6 +64,9 @@ class ParameterDefinition:
minimum: float | None = None
maximum: float | None = None
minimum_exclusive: bool = False
editor: ParameterEditor | None = None
options: tuple[ParameterOption, ...] = ()
description: str = ""
def validation_message(self, value: float) -> str | None:
if not isfinite(value):
@@ -57,6 +78,11 @@ class ParameterDefinition:
return f"must be at least {self.minimum:g}"
if self.maximum is not None and value > self.maximum:
return f"must be at most {self.maximum:g}"
if self.options and value not in {
float(option.value) for option in self.options
}:
available = ", ".join(f"{option.value:g}" for option in self.options)
return f"must be one of {available}"
return None
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
@@ -72,6 +98,14 @@ class ParameterDefinition:
payload["minimum"] = self.minimum
if self.maximum is not None:
payload["maximum"] = self.maximum
if self.editor is not None:
payload["editor"] = self.editor
if self.options:
payload["options"] = [
option.as_interface_dict() for option in self.options
]
if self.description:
payload["description"] = self.description
if value is not None:
payload["value"] = value
return payload
@@ -1,5 +1,10 @@
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,
@@ -10,7 +15,10 @@ from app.simulation.examples.test_mql.run import (
__all__ = [
"PreparedTestMqlRun",
"TestMqlRunConfig",
"TestMqlRunResult",
"TestMqlSimulationResult",
"TestMqlSystem",
"prepare_test_mql_run",
"run_prepared_test_mql",
"run_test_mql",
@@ -3,17 +3,17 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
from PythonModels.reporting.test_mql_observations import (
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 PythonModels.reporting.test_mql_output_schema import (
from app.simulation.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
from app.simulation.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
@@ -3,21 +3,21 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.components.amesim_pneumatic_line import (
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
AmesimPnl0001Pipe,
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
from PythonModels.core.medium import ThermodynamicProperties
from PythonModels.core.ports import PortState
from PythonModels.core.state import VolumeState
from PythonModels.systems.test_mql_nodes import (
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,
@@ -3,28 +3,28 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import m3_to_cm3
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
from PythonModels.reporting.test_mql_observations import (
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 PythonModels.reporting.test_mql_output_schema import (
from app.simulation.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
from app.simulation.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
)
from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog
from PythonModels.systems.test_mql_mechanical import (
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 PythonModels.systems.test_mql_pneumatic import (
from app.simulation.examples.test_mql.pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@@ -6,8 +6,8 @@ from dataclasses import dataclass
from math import isfinite
from typing import Any
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from PythonModels.systems.test_mql import COMPONENT_SPECS, GLOBAL_PARAMETERS
from app.simulation.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, GLOBAL_PARAMETERS
_BINARY_OPERATORS = {
@@ -5,8 +5,8 @@ import tarfile
from dataclasses import dataclass
from pathlib import Path
from PythonModels.systems.test_mql import CONNECTION_SPECS, GLOBAL_PARAMETERS
from PythonModels.systems.test_mql_config import resolve_numeric_expression
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
@@ -4,12 +4,12 @@ import re
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql import CONNECTION_SPECS
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
@@ -2,19 +2,19 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_mechanical import (
from app.simulation.examples.test_mql.primitives.mechanical import (
AmesimElasticEndstop,
AmesimMassFrictionEndstops,
AmesimPistonGeometry,
circular_area,
mm_to_m,
)
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
MM_TO_M = 1.0e-3
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.systems.test_mql import COMPONENT_SPECS
from app.simulation.examples.test_mql.system import COMPONENT_SPECS
@dataclass(frozen=True)
@@ -2,14 +2,14 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
AmesimVariablePneumaticVolume,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
from app.simulation.examples.test_mql.config import TestMqlConfig, TestMqlResolvedComponent
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
@@ -162,7 +162,7 @@ def build_test_mql_pneumatic_assembly(
def _build_variable_orifice_controls(
config: TestMqlConfig,
) -> dict[str, TestMqlVariableOrificeControl]:
from PythonModels.systems.test_mql import CONNECTION_SPECS
from app.simulation.examples.test_mql.system import CONNECTION_SPECS
components_by_alias = {component.alias: component for component in config.components}
variable_orifice_aliases = {
@@ -3,17 +3,17 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
)
from PythonModels.components.amesim_pneumatic_line import (
from app.simulation.examples.test_mql.primitives.pneumatic_lines import (
AmesimPnl0001Pipe,
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
from PythonModels.systems.test_mql_line_parameters import (
from app.simulation.examples.test_mql.line_parameters import (
TestMqlPnl0001Spec,
TestMqlPnl0002Spec,
TestMqlPnl0003Spec,
@@ -1,7 +1,7 @@
from __future__ import annotations
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
from app.simulation.examples.test_mql.closure import TestMqlPneumaticChamberSegmentSpec
from app.simulation.examples.test_mql.topology import TestMqlCirTopology
def discover_fixed_chamber_segments(
@@ -0,0 +1 @@
"""Calibrated component primitives used only by the ``test_mql`` example."""
@@ -0,0 +1,168 @@
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
@@ -0,0 +1,437 @@
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
@@ -0,0 +1,881 @@
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)
)
+20 -2
View File
@@ -5,8 +5,7 @@ from datetime import UTC, datetime
from pathlib import Path
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
from PythonModels.scripts.run_test_mql import format_test_mql_summary
from PythonModels.systems.test_mql import (
from app.simulation.examples.test_mql.system import (
TestMqlRunConfig,
TestMqlSimulationResult,
TestMqlSystem,
@@ -29,6 +28,25 @@ class TestMqlRunResult:
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
@@ -4,16 +4,20 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.systems.test_mql_baseline import (
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: Path(__file__).resolve().parents[2] / "AmesimModels" / "test_mql.ame"
default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH
)
output_dir: Path | None = None
@@ -31,9 +35,8 @@ class TestMqlBaselineScriptConfig:
def _default_output_dir() -> Path:
pythonmodels_root = Path(__file__).resolve().parents[1]
timestamp = datetime.now(UTC).strftime("test_mql_baseline_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / timestamp
return SIMULATION_RUNS_DIR / timestamp
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
@@ -7,30 +7,34 @@ from datetime import UTC, datetime
from math import nextafter, sqrt
from pathlib import Path
from PythonModels.components.amesim_pneumatic import AmesimPneumaticGas
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from PythonModels.reporting.test_mql_comparison import (
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 PythonModels.reporting.test_mql_output_schema import (
from app.simulation.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
from app.simulation.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
)
from PythonModels.systems.test_mql import (
from app.simulation.examples.test_mql.system import (
TestMqlPnl0001LineRhsDiagnostic,
TestMqlSimulationResult,
TestMqlSystem,
TestMqlVariableChamberRhsDiagnostic,
)
from PythonModels.systems.test_mql_pneumatic import AMESIM_REFERENCE_PRESSURE_PA
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 = (
@@ -758,9 +762,7 @@ class TestMqlPnvoEventWindowCandidateComparisonDiagnostic:
@dataclass(frozen=True)
class TestMqlFullStateComparisonPathConfig:
archive_path: Path = field(
default_factory=lambda: Path(__file__).resolve().parents[2]
/ "AmesimModels"
/ "test_mql.ame"
default_factory=lambda: AMESIM_TEST_MQL_ARCHIVE_PATH
)
amesim_results_archive_path: Path | None = None
output_dir: Path | None = None
@@ -798,9 +800,8 @@ class TestMqlFullStateComparisonScriptConfig:
def _default_output_dir() -> Path:
pythonmodels_root = Path(__file__).resolve().parents[1]
timestamp = datetime.now(UTC).strftime("test_mql_full_state_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / timestamp
return SIMULATION_RUNS_DIR / timestamp
def run_test_mql_full_state_comparison(
@@ -0,0 +1,18 @@
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"]
@@ -0,0 +1,78 @@
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)
@@ -4,13 +4,13 @@ from dataclasses import dataclass, field
from pathlib import Path
from types import SimpleNamespace
from PythonModels.core.base import Component
from PythonModels.core.network import SimulationNetwork
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
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"
AMESIM_ARCHIVE_RELATIVE_PATH = Path("AmesimModels/test_mql.ame")
# Extracted from AmesimModels/test_mql.ame on the model-development branch.
# This is a structural Python port scaffold: it preserves the AMESim component
@@ -4237,7 +4237,7 @@ class TestMqlFullStateClosure:
state_vector: list[float],
data_paths: tuple[str, ...] | list[str] | None = None,
) -> dict[str, float]:
from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa
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)
@@ -4456,7 +4456,7 @@ class TestMqlFullStateClosure:
}
def _piston_force_by_mass_alias(self, pneumatic_snapshot: object) -> dict[str, float]:
from PythonModels.systems.test_mql_pneumatic import pressure_to_amesim_gauge_pa
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:
@@ -4510,7 +4510,7 @@ 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 Path(__file__).resolve().parents[2] / AMESIM_ARCHIVE_RELATIVE_PATH
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()
@@ -4537,40 +4537,40 @@ class TestMqlSystem:
@staticmethod
def _build_pneumatic_assembly():
# Local import avoids a module cycle: test_mql_config reads constants from this module.
from PythonModels.systems.test_mql_pneumatic import build_test_mql_pneumatic_assembly
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 PythonModels.systems.test_mql_mechanical import build_test_mql_mechanical_assembly
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 PythonModels.systems.test_mql_pneumatic_lines import (
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 PythonModels.systems.test_mql_pneumatic_lines import (
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 PythonModels.systems.test_mql_pneumatic_lines import (
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 PythonModels.systems.test_mql_pneumatic_lines import (
from app.simulation.examples.test_mql.pneumatic_lines import (
build_test_mql_pnl00r_assembly,
)
@@ -4578,13 +4578,13 @@ class TestMqlSystem:
@staticmethod
def _build_node3_assembly():
from PythonModels.systems.test_mql_nodes import build_test_mql_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 PythonModels.systems.test_mql_nodes import build_test_mql_node4_assembly
from app.simulation.examples.test_mql.nodes import build_test_mql_node4_assembly
return build_test_mql_node4_assembly()
@@ -4637,7 +4637,7 @@ class TestMqlSystem:
return self.pnl0001_assembly.lines[spec.alias]
def _pnl0002_connection_for_node_port(self, node_alias: str, port_name: str):
from PythonModels.systems.test_mql_nodes import TestMqlP4NodePortConnection
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:
@@ -4674,7 +4674,7 @@ class TestMqlSystem:
return self.pnl0002_assembly.lines[spec.alias]
def _p4_primary_connection_for_node(self, node_alias: str):
from PythonModels.systems.test_mql_nodes import TestMqlP4NodePrimaryConnection
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":
@@ -4694,7 +4694,7 @@ class TestMqlSystem:
)
def _p4_port4_orifice_connection_for_node(self, node_alias: str):
from PythonModels.systems.test_mql_nodes import TestMqlP4NodeOrificeConnection
from app.simulation.examples.test_mql.nodes import TestMqlP4NodeOrificeConnection
for spec in CONNECTION_SPECS:
if spec["submodel"] != "DIRECT":
@@ -4720,7 +4720,7 @@ class TestMqlSystem:
raise KeyError(f"No PNVO orifice attached to {node_alias}.port_4")
def p4_node_neighborhood(self, node_alias: str):
from PythonModels.systems.test_mql_nodes import TestMqlP4NodeNeighborhood
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}")
@@ -4774,7 +4774,7 @@ class TestMqlSystem:
return self.network.initial_state_vector()
def mechanical_mass_closure(self):
from PythonModels.systems.test_mql_mechanical import TestMqlMechanicalMassClosure
from app.simulation.examples.test_mql.mechanical import TestMqlMechanicalMassClosure
return TestMqlMechanicalMassClosure(self.mechanical_assembly)
@@ -4900,7 +4900,7 @@ class TestMqlSystem:
downstream_volume_alias: str,
source: str = "manual",
):
from PythonModels.systems.test_mql_closure import TestMqlPneumaticBranchSpec
from app.simulation.examples.test_mql.closure import TestMqlPneumaticBranchSpec
return TestMqlPneumaticBranchSpec(
name=name,
@@ -4911,14 +4911,14 @@ class TestMqlSystem:
)
def discover_pneumatic_branch_topology(self):
from PythonModels.systems.test_mql_closure import (
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticTopologyCandidate,
TestMqlPneumaticTopologyDiscovery,
)
from PythonModels.systems.test_mql_pneumatic_topology import (
from app.simulation.examples.test_mql.pneumatic_topology import (
discover_fixed_chamber_segments,
)
from PythonModels.systems.test_mql_topology import load_test_mql_cir_topology
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(
@@ -4999,11 +4999,11 @@ class TestMqlSystem:
Boundary values apply at the PNL0001/orifice interfaces, not at the nodes.
"""
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.systems.test_mql_closure import (
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticBoundaryCondition,
TestMqlPneumaticChamberSegmentClosure,
TestMqlPneumaticChamberSegmentComponents,
@@ -5076,11 +5076,11 @@ class TestMqlSystem:
outlet_temperature_k: float = 293.15,
):
"""Insert the topology-derived inlet PNL0001 into a chamber segment."""
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.systems.test_mql_closure import (
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticBoundaryCondition,
TestMqlPnl0001ChamberSegmentClosure,
TestMqlPnl0001ChamberSegmentComponents,
@@ -5154,11 +5154,11 @@ class TestMqlSystem:
outlet_node_temperature_k: float = 293.15,
):
"""Insert both topology-derived PNL0001 states into the segment."""
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.systems.test_mql_closure import (
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticBoundaryCondition,
TestMqlPnl0001PairChamberSegmentClosure,
TestMqlPnl0001PairChamberSegmentComponents,
@@ -5241,11 +5241,11 @@ class TestMqlSystem:
port-1 compliance. The PNL0003 port-2 side is connected through the
real PNVO001 orifice to a prescribed P4 pressure boundary.
"""
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.systems.test_mql_closure import (
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticBoundaryCondition,
TestMqlPn3NodeChamberSegmentClosure,
TestMqlPn3NodeChamberSegmentComponents,
@@ -5354,11 +5354,11 @@ class TestMqlSystem:
PNVO upstream endpoints are closed through their adjacent PN3/PNL0003
subnets.
"""
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.systems.test_mql_closure import (
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticBoundaryCondition,
TestMqlPn3P4NodeChamberSegmentClosure,
TestMqlPn3P4NodeChamberSegmentComponents,
@@ -6140,11 +6140,11 @@ class TestMqlSystem:
downstream_volume_alias: str,
spec=None,
):
from PythonModels.components.amesim_pneumatic import (
from app.simulation.examples.test_mql.primitives.pneumatic import (
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
)
from PythonModels.systems.test_mql_closure import (
from app.simulation.examples.test_mql.closure import (
TestMqlPneumaticBranchComponents,
TestMqlPneumaticBranchSpec,
TestMqlPneumaticClosure,
@@ -1,74 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.systems.test_mql import TestMqlRunConfig, TestMqlSystem
@dataclass(frozen=True)
class TestMqlPathConfig:
output_dir: Path | None = None
@dataclass(frozen=True)
class TestMqlExecutionConfig:
write_summary: bool = True
@dataclass(frozen=True)
class TestMqlScriptConfig:
run: TestMqlRunConfig = field(default_factory=TestMqlRunConfig)
paths: TestMqlPathConfig = field(default_factory=TestMqlPathConfig)
execution: TestMqlExecutionConfig = field(default_factory=TestMqlExecutionConfig)
def _default_output_dir() -> Path:
pythonmodels_root = Path(__file__).resolve().parents[1]
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / timestamp
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 run_test_mql(config: TestMqlScriptConfig | None = None):
config = config or TestMqlScriptConfig()
system = TestMqlSystem()
result = system.simulate(config.run)
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_model_summary.txt").write_text(
format_test_mql_summary(system),
encoding="utf-8",
)
return system, result, output_dir
def main() -> None:
system, result, output_dir = run_test_mql()
print(format_test_mql_summary(system), end="")
print(f"Samples: {len(result.t)}")
print(f"Output directory: {output_dir}")
if __name__ == "__main__":
main()
+1
View File
@@ -16,6 +16,7 @@ DATA_DIR = (
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
AMESIM_TEST_MQL_ARCHIVE_PATH = PROJECT_ROOT / "AmesimModels" / "test_mql.ame"
MODELICA_TESTMODEL_RESULT_PATH = (
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
)
+88 -3
View File
@@ -17,9 +17,10 @@ from app.simulation.core.catalog import (
from app.simulation.core.metadata import (
SI_UNIT_BY_QUANTITY,
ParameterDefinition,
ParameterOption,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.medium import GasMedium, IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
@@ -81,7 +82,7 @@ class ComponentModelSpec:
ports.append(payload)
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
return {
payload: dict[str, object] = {
"type": self.model_type,
"modelType": self.model_type,
"modelVersion": self.model_version,
@@ -94,11 +95,14 @@ class ComponentModelSpec:
parameter.as_interface_dict() for parameter in self.parameters
],
}
if self.display.role is not None:
payload["role"] = self.display.role
return payload
def create(
self,
name: str,
medium: IdealGasMedium,
medium: GasMedium,
values: Mapping[str, float],
) -> Component:
unknown = sorted(set(values) - set(self.parameter_by_name))
@@ -410,6 +414,14 @@ def _validate_parameter(
raise ValueError(f"{field} declarations must use ParameterDefinition.")
_validate_member_id(parameter.name, field=f"{field} name")
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
if not isinstance(parameter.description, str):
raise ValueError(
f"{field} '{parameter.name}' description must be a string."
)
if parameter.description and not parameter.description.strip():
raise ValueError(
f"{field} '{parameter.name}' description must not be blank."
)
_validate_quantity_unit(
parameter.quantity,
parameter.unit,
@@ -433,6 +445,74 @@ def _validate_parameter(
raise ValueError(
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
)
if parameter.editor not in {
None,
"amesimGasReference",
"amesimGasPropertyModel",
}:
raise ValueError(
f"{field} '{parameter.name}' uses unsupported editor "
f"'{parameter.editor}'."
)
if not isinstance(parameter.options, tuple):
raise ValueError(
f"{field} '{parameter.name}' options must use a tuple."
)
if parameter.editor == "amesimGasPropertyModel" and not parameter.options:
raise ValueError(
f"{field} '{parameter.name}' property-model editor must declare options."
)
if parameter.options and parameter.editor != "amesimGasPropertyModel":
raise ValueError(
f"{field} '{parameter.name}' options require the "
"'amesimGasPropertyModel' editor."
)
option_values: list[float] = []
for option in parameter.options:
if not isinstance(option, ParameterOption):
raise ValueError(
f"{field} '{parameter.name}' options must use ParameterOption."
)
if (
isinstance(option.value, bool)
or not isinstance(option.value, (int, float))
or not isfinite(option.value)
):
raise ValueError(
f"{field} '{parameter.name}' option values must be finite numbers."
)
_validate_label(
option.label,
field=(
f"{field} '{parameter.name}' option "
f"'{option.value:g}' label"
),
)
numeric_option = float(option.value)
if (
parameter.editor == "amesimGasPropertyModel"
and not numeric_option.is_integer()
):
raise ValueError(
f"{field} '{parameter.name}' property-model option values "
"must be integers."
)
option_message = parameter.validation_message(numeric_option)
if option_message is not None:
raise ValueError(
f"{field} '{parameter.name}' option {numeric_option:g} "
f"{option_message}."
)
option_values.append(numeric_option)
duplicate_option_values = sorted(
{value for value in option_values if option_values.count(value) > 1}
)
if duplicate_option_values:
duplicates = ", ".join(f"{value:g}" for value in duplicate_option_values)
raise ValueError(
f"{field} '{parameter.name}' contains duplicate option values: "
f"{duplicates}."
)
if (
parameter.minimum is not None
and parameter.maximum is not None
@@ -532,6 +612,11 @@ def validate_component_model_class(
field=f"Component '{model_type}' display symbol",
)
_validate_order(display.order, field=f"Component '{model_type}' display order")
if display.role not in {None, "amesimGasMediumDefinition"}:
raise ValueError(
f"Component '{model_type}' DISPLAY uses unsupported role "
f"'{display.role}'."
)
if not isinstance(display.ports, tuple):
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
if display.library_id != library.id:
@@ -2,13 +2,13 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_pneumatic import (
from app.simulation.examples.test_mql.pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@@ -5,7 +5,7 @@ import csv
from dataclasses import dataclass
from pathlib import Path
from PythonModels.reporting.amesim_results import AmesimResults
from app.simulation.reporting.amesim_results import AmesimResults
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
@@ -2,13 +2,13 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_lines import (
from app.simulation.examples.test_mql.lines import (
TestMqlLineAssembly,
build_test_mql_line_assembly,
)
@@ -2,13 +2,13 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_mechanical import (
from app.simulation.examples.test_mql.mechanical import (
TestMqlMechanicalAssembly,
build_test_mql_mechanical_assembly,
)
@@ -2,24 +2,24 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_chamber_observations import (
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 PythonModels.reporting.test_mql_line_observations import (
from app.simulation.reporting.test_mql_line_observations import (
TestMqlLineObservationCatalog,
build_test_mql_line_observation_catalog,
)
from PythonModels.reporting.test_mql_mechanical_observations import (
from app.simulation.reporting.test_mql_mechanical_observations import (
TestMqlMechanicalObservationCatalog,
build_test_mql_mechanical_observation_catalog,
)
from PythonModels.reporting.test_mql_orifice_observations import (
from app.simulation.reporting.test_mql_orifice_observations import (
TestMqlOrificeObservationCatalog,
build_test_mql_orifice_observation_catalog,
)
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
@@ -2,13 +2,13 @@ from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_pneumatic import (
from app.simulation.examples.test_mql.pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@@ -3,12 +3,12 @@ from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_observations import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_observations import (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_variables import TestMqlVariableBinding
from app.simulation.reporting.test_mql_variables import TestMqlVariableBinding
@dataclass(frozen=True)
@@ -3,12 +3,12 @@ from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_comparison import (
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_comparison import (
TestMqlComparisonResult,
compare_test_mql_series,
)
from PythonModels.reporting.test_mql_output_schema import TestMqlOutputSchema
from app.simulation.reporting.test_mql_output_schema import TestMqlOutputSchema
class TestMqlOutputValidationError(ValueError):
@@ -4,8 +4,8 @@ import re
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults, AmesimVariable
from PythonModels.systems.test_mql import COMPONENT_SPECS, CONNECTION_SPECS
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*$")
+23 -13
View File
@@ -21,6 +21,7 @@ class SolveIVPConfig:
rtol: float = 1e-6
atol: float = 1e-8
max_step: float = 1e-3
first_step: float | None = None
@dataclass(frozen=True)
@@ -162,15 +163,21 @@ def _integrate_scipy_stepwise(
status="cancelled",
)
solver_options = {
"rtol": config.rtol,
"atol": config.atol,
"max_step": config.max_step,
}
if config.first_step is not None:
solver_options["first_step"] = config.first_step
try:
solver = solver_type(
cancellable_rhs,
config.t_start,
np.asarray(initial_state, dtype=float),
config.t_stop,
rtol=config.rtol,
atol=config.atol,
max_step=config.max_step,
**solver_options,
)
except _IntegrationCancelled:
return ODESolution(
@@ -296,13 +303,16 @@ def integrate_ode(
accepted_step_callback,
)
return solve_ivp(
fun=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,
)
solve_options = {
"fun": 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
return solve_ivp(**solve_options)