feat: integrate AMESim media models and editor UI
This commit is contained in:
1 parent
3e4f466114
commit
e7177ab03e
181 files changed
+7042
-25270
No files matched your search
+23
-34
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-5
@@ -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,
|
||||
+6
-6
@@ -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,
|
||||
+9
-9
@@ -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,
|
||||
)
|
||||
+2
-2
@@ -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 = {
|
||||
+2
-2
@@ -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
|
||||
+3
-3
@@ -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")
|
||||
+4
-4
@@ -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
|
||||
+1
-1
@@ -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)
|
||||
+3
-3
@@ -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
-3
@@ -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,
|
||||
+2
-2
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
+7
-4
@@ -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:
|
||||
+14
-13
@@ -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)
|
||||
|
||||
+36
-36
@@ -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,
|
||||
File renamed without changes.
@@ -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()
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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*$")
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user