Merge model-development into main

This commit is contained in:
huojiarong committed 2026-07-30 10:53:35 +00:00
commit 127ec36a55
218 files changed
+65243 -47

No files matched your search

+46 -12
View File
@@ -2,9 +2,9 @@
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
目标不是逐行翻译源模型,而是建立可运行、可测试、可导出,并能与 OpenModelica 或 AMESim baseline 对比的 Python 仿真框架。
当前状态不是“只有骨架”,而是“`Testmodel` 已有一版可运行的 ODE 近似实现,并具备基础结果导出与对比能力”。
当前包含两条模型线:`Testmodel` 已有可运行的 ODE 近似和 OpenModelica 对比能力;`test_mql` 已形成 132 状态气动机械总闭包,正在按 AMESim baseline 做数值校准。
## 当前目录
@@ -14,9 +14,11 @@
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
- `components/experimental/junctions/`: 三通等连接节点。
- `components/amesim/`: AMESim 气动、信号和机械组件原语。
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑、基线运行入口,以及 test_mql 当前迁移过程中的系统装配和诊断脚本。
- `examples/test_mql/`: AMESim `test_mql` 的前端调用运行入口。
- `reporting/`: CSV、SVG、运行报告、Modelica 对比结果、AMESim 结果读取和诊断报告导出。
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
@@ -35,17 +37,21 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
当前关键文件:
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
- `core/medium.py`: 温度相关的理想气体近似介质 `IdealGasMedium`
- `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、流量计算和端口写回
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
- `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 基线运行与程序化执行入口
- `tests/`: 当前组件契约、XML、通用系统、AMESim 迁移和结果导出测试
## 当前阶段进度
@@ -279,7 +285,35 @@ print(result.used_modelica_reference)
这说明在当前基线工况下,Python 版主变量已经能较好贴近 OpenModelica 结果。
## 当前架构判断
## AMESim test_mql 当前进度
`test_mql` 是从 `AmesimModels/test_mql.ame` 新增迁移的 AMESim 模型,当前只在独立路径下推进,不修改旧 `testmodel`。新增命名保持 AMESim 原始别名和 `Data_Path`,方便后续逐变量对齐。
当前已经完成:
- 解析 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`。
## Testmodel 当前架构判断
如果按“组件正确 -> 网络闭合 -> 积分可跑 -> 结果对齐 -> 去近似”来看,当前大致处于:
@@ -293,7 +327,7 @@ print(result.used_modelica_reference)
`Testmodel` 已有一版可运行、可导出、可对比的 Python 近似实现。
## 已知限制
## Testmodel 已知限制
当前最主要的限制可以直接理解成下面几条:
@@ -319,7 +353,7 @@ print(result.used_modelica_reference)
- 作为最终工程结论的唯一依据
- 直接扩展到更复杂拓扑而不补通用连接器语义
## 文件级现状
## Testmodel 文件级现状
按代码现状逐项看:
@@ -342,7 +376,7 @@ print(result.used_modelica_reference)
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
## 当前主技术债
## Testmodel 当前主技术债
目前最主要的技术债,可以直接理解成下面 4 件事:
@@ -0,0 +1,3 @@
from __future__ import annotations
__all__: list[str] = []
@@ -0,0 +1 @@
"""AMESim pneumatic boundary components."""
@@ -0,0 +1,64 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class AmesimPnpl01(AlgebraicComponent):
"""AMESim PNPL01 zero pneumatic flow source.
The component behaves as a sealed pneumatic boundary in the current acausal
solver: it does not prescribe pressure, and only constrains its port mass
flow to zero.
"""
MODEL_TYPE = "amesim_pnpl01"
MODEL_VERSION = "0.1.0"
PORTS = (PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="PNPL01 零气动流边界",
library_id="amesim",
category_id="boundary",
symbol="generic",
ports=(PortDisplaySpec("port_1", "left", order=10),),
order=10,
)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.port_1 = self.register_declared_port("port_1")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnpl01:
return cls(name=name)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:zero_mass_flow",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.m_flow",),
role="flow",
value=self.port_1.m_flow,
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
if "port_1" in connected_h:
self.port_1.h_outflow = connected_h["port_1"]
@@ -0,0 +1 @@
"""AMESim pneumatic flow components."""
@@ -0,0 +1,701 @@
from __future__ import annotations
from collections.abc import Mapping
from math import isclose, sqrt
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
)
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class AmesimPnor001(AlgebraicComponent):
"""AMESim PNOR001 constant-flow-coefficient pneumatic orifice.
This public component preserves the PNOR001 catalog/XML contract and uses a
finite bidirectional compressible-orifice approximation. The Siemens
`pn2rcqfix_` details remain a later calibration target.
"""
MODEL_TYPE = "amesim_pnor001"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
"cq",
0.72,
label="流量系数 Cq",
quantity="dimensionless",
unit="",
minimum=1.0e-10,
maximum=1.0,
),
ParameterDefinition(
"area",
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,
),
)
RESULT_VARIABLES = (
ResultVariableDefinition(
"cm",
label="质量流量参数",
quantity="dimensionless",
unit="",
category="derived",
order=10,
),
ResultVariableDefinition(
"gasvel",
label="缩流截面气体速度",
quantity="velocity",
unit="m/s",
category="derived",
order=20,
),
)
DISPLAY = ComponentDisplaySpec(
label="PNOR001 常系数气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=10,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cq: float = 0.72,
area: float = 5.0e-6,
Cv: float = 0.5,
Kv: float = 0.4,
gi: float = 1.0,
flowset: float = 1.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"cq": cq,
"area": area,
"Cv": Cv,
"Kv": Kv,
"gi": gi,
"flowset": flowset,
}
)
self.medium = medium
self.cq = float(cq)
self.area = float(area)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", 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.")
initial_h = medium.specific_enthalpy(medium.T_ref)
self.port_1 = self.register_declared_port("port_1")
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError(f"PNOR001 parameter {name} must be an integer value.")
return int(rounded)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnor001:
return cls(
name=name,
medium=medium,
cq=parameters["cq"],
area=parameters["area"],
Cv=parameters["Cv"],
Kv=parameters["Kv"],
gi=parameters["gi"],
flowset=parameters["flowset"],
)
@property
def effective_cq(self) -> float:
return self.cq if self.flowset == 1 else 0.72
@property
def effective_area(self) -> float:
if self.flowset == 1:
return self.area
if self.flowset == 2:
return self._area_from_cv(self.Cv, self.effective_cq)
return self._area_from_kv(self.Kv, self.effective_cq)
@staticmethod
def _area_from_cv(Cv: float, cq: float) -> float:
water_density = 999.0
reference_flow_m3_s = Cv * 6.30901964e-5
reference_dp_pa = 6894.75729
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
@staticmethod
def _area_from_kv(Kv: float, cq: float) -> float:
water_density = 999.0
reference_flow_m3_s = Kv / 3600.0
reference_dp_pa = 100000.0
return reference_flow_m3_s / (cq * sqrt(2.0 * reference_dp_pa / water_density))
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return self.medium.T_ref
def mass_flow(self, p_1: float, p_2: float) -> float:
if p_1 == p_2 or self.effective_area == 0.0:
return 0.0
if p_1 > p_2:
return self._one_way_mass_flow(
upstream_pressure=p_1,
downstream_pressure=p_2,
upstream_temperature=self._upstream_temperature("port_1"),
)
return -self._one_way_mass_flow(
upstream_pressure=p_2,
downstream_pressure=p_1,
upstream_temperature=self._upstream_temperature("port_2"),
)
def _one_way_mass_flow(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> float:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma = max(self.medium.gamma, 1.000001)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
if pressure_ratio <= critical_ratio:
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
2.0 / (gamma + 1.0)
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
else:
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
(gamma + 1.0) / gamma
)
flow_factor = sqrt(
max(
2.0
* gamma
* expansion
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
0.0,
)
)
return self.effective_cq * self.effective_area * p_up * flow_factor
def component_result_values(self) -> Mapping[str, float]:
p_1 = max(self.port_1.p, 1.0)
p_2 = max(self.port_2.p, 1.0)
m_flow = abs(self.mass_flow(self.port_1.p, self.port_2.p))
upstream_pressure = max(p_1, p_2)
upstream_temperature = self._upstream_temperature(
"port_1" if p_1 >= p_2 else "port_2"
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
return {
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
"gasvel": m_flow / (density * area),
}
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_1.m_flow",
f"{self.name}.port_2.m_flow",
),
role="flow",
value=self.port_1.m_flow + self.port_2.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_1.p",
f"{self.name}.port_2.p",
f"{self.name}.port_1.m_flow",
),
role="flow",
value=self.port_1.m_flow
- self.mass_flow(self.port_1.p, self.port_2.p),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_1.h_outflow = connected_h["port_2"]
self.port_2.h_outflow = connected_h["port_1"]
class AmesimPnvo001FixedOpening(AlgebraicComponent):
"""Fixed-opening public variant of AMESim PNVO001.
Full PNVO001 has a signal input port. The current public component library
does not support signal simulation, so this model exposes the pneumatic
ports and replaces the signal with a normalized `opening` parameter.
"""
MODEL_TYPE = "amesim_pnvo001_fixed"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
)
PARAMETERS = (
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(
"opening",
1.0,
label="固定开度",
quantity="dimensionless",
unit="",
minimum=0.0,
maximum=1.0,
),
)
RESULT_VARIABLES = (
ResultVariableDefinition(
"xv",
label="有效开度",
quantity="dimensionless",
unit="",
category="derived",
order=10,
),
ResultVariableDefinition(
"cm",
label="质量流量参数",
quantity="dimensionless",
unit="",
category="derived",
order=20,
),
ResultVariableDefinition(
"gasvel",
label="缩流截面气体速度",
quantity="velocity",
unit="m/s",
category="derived",
order=30,
),
)
DISPLAY = ComponentDisplaySpec(
label="PNVO001 固定开度气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
ports=(
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
),
order=30,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cq: float = 0.72,
area0: float = 5.0e-6,
Cv: float = 0.5,
Kv: float = 0.4,
gi: float = 1.0,
flowset: float = 1.0,
opening: float = 1.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"cq": cq,
"area0": area0,
"Cv": Cv,
"Kv": Kv,
"gi": gi,
"flowset": flowset,
"opening": opening,
}
)
self.medium = medium
self.cq = float(cq)
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", 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.")
self.opening = min(1.0, max(0.0, float(opening)))
initial_h = medium.specific_enthalpy(medium.T_ref)
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
self.port_3 = self.register_declared_port("port_3")
self.port_3.h_outflow = initial_h
@staticmethod
def _integer_parameter(name: str, value: float) -> int:
rounded = round(value)
if not isclose(value, rounded, rel_tol=0.0, abs_tol=1.0e-12):
raise ValueError(f"PNVO001 fixed-opening parameter {name} must be an integer value.")
return int(rounded)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPnvo001FixedOpening:
return cls(
name=name,
medium=medium,
cq=parameters["cq"],
area0=parameters["area0"],
Cv=parameters["Cv"],
Kv=parameters["Kv"],
gi=parameters["gi"],
flowset=parameters["flowset"],
opening=parameters["opening"],
)
@property
def effective_cq(self) -> float:
return self.cq if self.flowset == 1 else 0.72
@property
def maximum_area(self) -> float:
if self.flowset == 1:
return self.area0
if self.flowset == 2:
return AmesimPnor001._area_from_cv(self.Cv, self.effective_cq)
return AmesimPnor001._area_from_kv(self.Kv, self.effective_cq)
@property
def effective_area(self) -> float:
return self.opening * self.maximum_area
def _upstream_temperature(self, port_name: str) -> float:
port = self.get_port(port_name)
if port.h_outflow > 0.0:
return max(port.h_outflow / self.medium.cp_ref, 1.0)
return self.medium.T_ref
def mass_flow(self, p_2: float, p_3: float) -> float:
if p_2 == p_3 or self.effective_area == 0.0:
return 0.0
if p_2 > p_3:
return self._one_way_mass_flow(
upstream_pressure=p_2,
downstream_pressure=p_3,
upstream_temperature=self._upstream_temperature("port_2"),
)
return -self._one_way_mass_flow(
upstream_pressure=p_3,
downstream_pressure=p_2,
upstream_temperature=self._upstream_temperature("port_3"),
)
def _one_way_mass_flow(
self,
*,
upstream_pressure: float,
downstream_pressure: float,
upstream_temperature: float,
) -> float:
p_up = max(upstream_pressure, 1.0)
p_down = max(min(downstream_pressure, p_up), 0.0)
T_up = max(upstream_temperature, 1.0)
gamma = max(self.medium.gamma, 1.000001)
pressure_ratio = max(p_down / p_up, 0.0)
critical_ratio = (2.0 / (gamma + 1.0)) ** (gamma / (gamma - 1.0))
if pressure_ratio <= critical_ratio:
flow_factor = sqrt(gamma / (self.medium.R_gas * T_up)) * (
2.0 / (gamma + 1.0)
) ** ((gamma + 1.0) / (2.0 * (gamma - 1.0)))
else:
expansion = pressure_ratio ** (2.0 / gamma) - pressure_ratio ** (
(gamma + 1.0) / gamma
)
flow_factor = sqrt(
max(
2.0
* gamma
* expansion
/ (self.medium.R_gas * T_up * (gamma - 1.0)),
0.0,
)
)
return self.effective_cq * self.effective_area * p_up * flow_factor
def component_result_values(self) -> Mapping[str, float]:
p_2 = max(self.port_2.p, 1.0)
p_3 = max(self.port_3.p, 1.0)
m_flow = abs(self.mass_flow(self.port_2.p, self.port_3.p))
upstream_pressure = max(p_2, p_3)
upstream_temperature = self._upstream_temperature(
"port_2" if p_2 >= p_3 else "port_3"
)
density = max(self.medium.density(upstream_pressure, upstream_temperature), 1.0e-12)
area = max(self.effective_area, 1.0e-18)
return {
"xv": self.opening,
"cm": m_flow / (self.effective_cq * area * upstream_pressure),
"gasvel": m_flow / (density * area),
}
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=(
f"{self.name}.port_2.m_flow",
f"{self.name}.port_3.m_flow",
),
role="flow",
value=self.port_2.m_flow + self.port_3.m_flow,
),
EquationResidual(
id=f"{self.name}:pressure_flow_relation",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(
f"{self.name}.port_2.p",
f"{self.name}.port_3.p",
f"{self.name}.port_2.m_flow",
),
role="flow",
value=self.port_2.m_flow
- self.mass_flow(self.port_2.p, self.port_3.p),
),
)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
self.port_2.h_outflow = connected_h["port_3"]
self.port_3.h_outflow = connected_h["port_2"]
class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
MODEL_TYPE = "amesim_pnvo001"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
)
PARAMETERS = (
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),
)
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="PNVO001 信号开度气动孔口",
library_id="amesim",
category_id="flow",
symbol="orifice",
ports=(
PortDisplaySpec("res", "left", order=5),
PortDisplaySpec("port_2", "left", order=10),
PortDisplaySpec("port_3", "right", order=20),
),
order=35,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cq: float = 0.72,
area0: float = 5.0e-6,
Cv: float = 0.5,
Kv: float = 0.4,
gi: float = 1.0,
flowset: float = 1.0,
opening0: float = 1.0,
) -> None:
AlgebraicComponent.__init__(self, name=name)
self.set_parameter_values(
{
"cq": cq,
"area0": area0,
"Cv": Cv,
"Kv": Kv,
"gi": gi,
"flowset": flowset,
"opening0": opening0,
}
)
self.medium = medium
self.cq = float(cq)
self.area0 = float(area0)
self.Cv = float(Cv)
self.Kv = float(Kv)
self.gi = self._integer_parameter("gi", 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.")
self.opening0 = min(1.0, max(0.0, float(opening0)))
self.res = self.register_declared_port("res")
self.res.signal = self.opening0
initial_h = medium.specific_enthalpy(medium.T_ref)
self.port_2 = self.register_declared_port("port_2")
self.port_2.h_outflow = initial_h
self.port_3 = self.register_declared_port("port_3")
self.port_3.h_outflow = initial_h
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnvo001SignalOpening":
return cls(name=name, medium=medium, **dict(parameters))
@property
def opening(self) -> float:
return min(1.0, max(0.0, self.res.signal))
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,5 @@
from __future__ import annotations
from app.simulation.components.amesim.junctions.nodes import AmesimP4Node2, AmesimPn3Node2
__all__ = ["AmesimP4Node2", "AmesimPn3Node2"]
@@ -0,0 +1,149 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortState
class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels."""
REFERENCE_PORT = "port_2"
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
for definition in self.PORTS:
setattr(self, definition.name, self.register_declared_port(definition.name))
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
reference = self.get_port(self.REFERENCE_PORT)
residuals: list[EquationResidual] = []
for definition in self.PORTS:
if definition.name == self.REFERENCE_PORT:
continue
port = self.get_port(definition.name)
residuals.append(
EquationResidual(
id=f"{self.name}:{definition.name}_pressure_reference",
owner="component",
owner_id=self.name,
relation="equal",
variables=(
f"{self.name}.{definition.name}.p",
f"{self.name}.{self.REFERENCE_PORT}.p",
),
role="effort",
value=port.p - reference.p,
)
)
residuals.append(
EquationResidual(
id=f"{self.name}:mass_flow_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=tuple(
f"{self.name}.{definition.name}.m_flow"
for definition in self.PORTS
),
role="flow",
value=sum(self.get_port(definition.name).m_flow for definition in self.PORTS),
)
)
return tuple(residuals)
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
incoming = [
(port.m_flow, connected_h[name])
for name, port in self.ports.items()
if port.m_flow > 1e-12
]
total_flow = sum(m_flow for m_flow, _ in incoming)
if total_flow > 1e-12:
mixed_h = sum(m_flow * h for m_flow, h in incoming) / total_flow
else:
mixed_h = connected_h.get(
self.REFERENCE_PORT,
sum(connected_h.values()) / len(connected_h) if connected_h else 0.0,
)
for port in self.ports.values():
port.h_outflow = mixed_h
class AmesimPn3Node2(_AmesimPneumaticNode):
"""AMESim PN3NODE2 pneumatic three-port junction."""
MODEL_TYPE = "amesim_pn3node2"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="PN3NODE2 三端气动节点",
library_id="amesim",
category_id="junctions",
symbol="tee",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
PortDisplaySpec("port_3", "right", order=30),
),
order=10,
)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimPn3Node2:
return cls(name=name)
class AmesimP4Node2(_AmesimPneumaticNode):
"""AMESim P4NODE2 pneumatic four-port junction."""
MODEL_TYPE = "amesim_p4node2"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="P4NODE2 四端气动节点",
library_id="amesim",
category_id="junctions",
symbol="generic",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
PortDisplaySpec("port_3", "right", order=30),
PortDisplaySpec("port_4", "right", order=40),
),
order=20,
)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> AmesimP4Node2:
return cls(name=name)
@@ -0,0 +1,45 @@
"""AMESim-compatible public component library."""
from app.simulation.core.catalog import (
ComponentCategorySpec,
ComponentLibrarySpec,
)
LIBRARY = ComponentLibrarySpec(
id="amesim",
label="AMESim 组件库",
version="0.1.0",
source_package="app.simulation.components.amesim",
temporary=True,
order=200,
categories=(
ComponentCategorySpec(id="storage", label="储能元件", order=10),
ComponentCategorySpec(id="flow", label="流动元件", order=20),
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
ComponentCategorySpec(id="boundary", label="边界元件", order=40),
ComponentCategorySpec(id="signals", label="信号元件", order=50),
ComponentCategorySpec(id="mechanical", label="机械元件", order=60),
),
models=(
"app.simulation.components.amesim.boundary.sources:AmesimPnpl01",
"app.simulation.components.amesim.signals.sources:AmesimStep0",
"app.simulation.components.amesim.signals.sources:AmesimUd00",
"app.simulation.components.amesim.mechanical.translational:AmesimF000",
"app.simulation.components.amesim.mechanical.translational:AmesimForc",
"app.simulation.components.amesim.mechanical.translational:AmesimMecmas21",
"app.simulation.components.amesim.mechanical.translational:AmesimLstp00a",
"app.simulation.components.amesim.mechanical.translational:AmesimLmechn1",
"app.simulation.components.amesim.storage.chambers:AmesimPnch023",
"app.simulation.components.amesim.storage.chambers:AmesimPnch012",
"app.simulation.components.amesim.flow.orifices:AmesimPnor001",
"app.simulation.components.amesim.flow.orifices:AmesimPnvo001FixedOpening",
"app.simulation.components.amesim.flow.orifices:AmesimPnvo001SignalOpening",
"app.simulation.components.amesim.flow.pipes:AmesimPnl00r",
"app.simulation.components.amesim.flow.pipes:AmesimPnl0001",
"app.simulation.components.amesim.flow.pipes:AmesimPnl0002",
"app.simulation.components.amesim.flow.pipes:AmesimPnl0003",
"app.simulation.components.amesim.junctions.nodes:AmesimPn3Node2",
"app.simulation.components.amesim.junctions.nodes:AmesimP4Node2",
),
)
@@ -0,0 +1 @@
"""AMESim mechanical components."""
@@ -0,0 +1,506 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent, DynamicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class AmesimF000(AlgebraicComponent):
"""AMESim F000 zero force source."""
MODEL_TYPE = "amesim_f000"
MODEL_VERSION = "0.1.0"
PORTS = (PortDefinition.mechanical_translational("port_1"),)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(
label="F000 零力源",
library_id="amesim",
category_id="mechanical",
symbol="generic",
ports=(PortDisplaySpec("port_1", "right", order=10),),
order=10,
)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.port_1 = self.register_declared_port("port_1")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimF000":
return cls(name=name)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:zero_force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.f",),
role="flow",
value=self.port_1.f,
),
)
class AmesimForc(AlgebraicComponent):
"""AMESim FORC signal-to-force converter."""
MODEL_TYPE = "amesim_forc"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
PortDefinition.mechanical_translational("port_2"),
)
PARAMETERS = ()
RESULT_VARIABLES = (
ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10),
)
DISPLAY = ComponentDisplaySpec(
label="FORC 信号转力",
library_id="amesim",
category_id="mechanical",
symbol="signal",
ports=(
PortDisplaySpec("res", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=20,
)
def __init__(self, name: str) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.res = self.register_declared_port("res")
self.port_2 = self.register_declared_port("port_2")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimForc":
return cls(name=name)
@property
def output_force(self) -> float:
return float(self.res.signal)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
EquationResidual(
id=f"{self.name}:signal_force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"),
role="flow",
value=self.port_2.f + self.output_force,
),
)
def component_result_values(self) -> Mapping[str, float]:
return {"force": self.output_force}
class AmesimMecmas21(DynamicComponent):
"""AMESim MECMAS21 first public one-dimensional translational mass."""
MODEL_TYPE = "amesim_mecmas21"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.mechanical_translational("port_1"),
PortDefinition.mechanical_translational("port_2"),
)
PARAMETERS = (
ParameterDefinition("mass", 1.0, label="质量", quantity="mass", unit="kg", minimum=0.0, minimum_exclusive=True),
ParameterDefinition("fstick", 0.0, label="静摩擦力", quantity="force", unit="N", minimum=0.0),
ParameterDefinition("fcoul", 0.0, label="库仑摩擦力", quantity="force", unit="N", minimum=0.0),
ParameterDefinition("rvisc", 0.0, label="黏性摩擦系数", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
ParameterDefinition("wind", 0.0, label="风阻系数", quantity="windage", unit="N/(m/s)^2", minimum=0.0),
ParameterDefinition("dvel", 1.0e-6, label="粘滞速度阈值", quantity="velocity", unit="m/s", minimum=0.0),
ParameterDefinition("restdvel", 1.0e-6, label="恢复速度阈值", quantity="velocity", unit="m/s", minimum=0.0),
ParameterDefinition("restcoeff", 0.65, label="恢复系数", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
ParameterDefinition("astrib", 1.0e-3, label="Stribeck 常数", quantity="velocity", unit="m/s", minimum=0.0),
ParameterDefinition("xmin", -1.0, label="下位移限位", quantity="length", unit="m"),
ParameterDefinition("Kbmin", 1.0e9, label="下限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
ParameterDefinition("Dbmin", 1.0e4, label="下限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
ParameterDefinition("Pdmin", 1.0e-4, label="下限位满阻尼穿透", quantity="length", unit="m", minimum=0.0),
ParameterDefinition("xmax", 0.8, label="上位移限位", quantity="length", unit="m"),
ParameterDefinition("Kbmax", 1.0e9, label="上限位刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
ParameterDefinition("Dbmax", 1.0e4, label="上限位阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
ParameterDefinition("Pdmax", 1.0e-4, label="上限位满阻尼穿透", quantity="length", unit="m", minimum=0.0),
ParameterDefinition("theta", 0.0, label="倾角", quantity="dimensionless", unit=""),
ParameterDefinition("useFriction", 1.0, label="启用摩擦", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
ParameterDefinition("stoptype", 4.0, label="限位类型", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("discContactOption", 1.0, label="接触选项", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("strib", 1.0, label="Stribeck 选项", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
ParameterDefinition("frictionType", 1.0, label="摩擦类型", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("v0", 0.0, label="初始速度", quantity="velocity", unit="m/s"),
ParameterDefinition("x0", 0.0, label="初始位移", quantity="length", unit="m"),
)
RESULT_VARIABLES = (
ResultVariableDefinition("a", "加速度", "acceleration", "m/s2", "state", 10),
ResultVariableDefinition("v", "速度", "velocity", "m/s", "state", 20),
ResultVariableDefinition("x", "位移", "length", "m", "state", 30),
ResultVariableDefinition("Fvisc", "黏性摩擦力", "force", "N", "derived", 40),
ResultVariableDefinition("Ffric", "干摩擦力", "force", "N", "derived", 50),
ResultVariableDefinition("Fmin", "下限位力", "force", "N", "derived", 60),
ResultVariableDefinition("Fmax", "上限位力", "force", "N", "derived", 70),
)
DISPLAY = ComponentDisplaySpec(
label="MECMAS21 一维质量",
library_id="amesim",
category_id="mechanical",
symbol="generic",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=30,
)
state_size = 2
def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None:
super().__init__(name=name)
resolved = {definition.name: parameters.get(definition.name, definition.default) for definition in self.PARAMETERS}
self.set_parameter_values(resolved)
for name, value in resolved.items():
setattr(self, name, float(value))
self.use_friction = bool(int(self.useFriction))
self.port_1 = self.register_declared_port("port_1")
self.port_2 = self.register_declared_port("port_2")
self.v = float(self.v0)
self.x = float(self.x0)
self.refresh_thermodynamic_ports()
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimMecmas21":
for integer_name in ("useFriction", "stoptype", "discContactOption", "strib", "frictionType"):
if not float(parameters[integer_name]).is_integer():
raise ValueError(f"MECMAS21 {integer_name} must be an integer.")
return cls(name=name, medium=medium, **dict(parameters))
def get_state_vector(self) -> list[float]:
return [self.v, self.x]
def set_state_vector(self, values: list[float]) -> None:
if len(values) != 2:
raise ValueError("MECMAS21 state vector requires [v, x].")
self.v = float(values[0])
self.x = float(values[1])
self.refresh_thermodynamic_ports()
def refresh_thermodynamic_ports(self) -> None:
for port in (self.port_1, self.port_2):
port.x = self.x
port.v = self.v
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
self._state_residual("port_1", "x", self.port_1.x - self.x),
self._state_residual("port_1", "v", self.port_1.v - self.v),
self._state_residual("port_2", "x", self.port_2.x - self.x),
self._state_residual("port_2", "v", self.port_2.v - self.v),
)
def _state_residual(self, port_name: str, variable: str, value: float) -> EquationResidual:
return EquationResidual(
id=f"{self.name}:{port_name}_{variable}_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.{port_name}.{variable}",),
role="effort",
value=value,
)
def _viscous_friction_force(self) -> float:
if not self.use_friction:
return 0.0
return -self.rvisc * self.v
def _windage_force(self) -> float:
if not self.use_friction:
return 0.0
return -self.wind * self.v * abs(self.v)
def _dry_friction_force(self) -> float:
if not self.use_friction:
return 0.0
if self.v > 0.0:
return -self.fcoul
if self.v < 0.0:
return self.fcoul
return 0.0
def _lower_limit_force(self) -> float:
penetration = max(self.xmin - self.x, 0.0)
if penetration <= 0.0:
return 0.0
return self.Kbmin * penetration + max(-self.Dbmin * self.v, 0.0)
def _upper_limit_force(self) -> float:
penetration = max(self.x - self.xmax, 0.0)
if penetration <= 0.0:
return 0.0
return self.Kbmax * penetration + max(self.Dbmax * self.v, 0.0)
def acceleration(self) -> float:
return (
self.port_1.f
+ self.port_2.f
+ self._viscous_friction_force()
+ self._windage_force()
+ self._dry_friction_force()
+ self._lower_limit_force()
- self._upper_limit_force()
) / self.mass
def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]:
return [self.acceleration(), self.v]
def component_result_values(self) -> Mapping[str, float]:
return {
"a": self.acceleration(),
"v": self.v,
"x": self.x,
"Fvisc": self._viscous_friction_force(),
"Ffric": self._dry_friction_force(),
"Fmin": self._lower_limit_force(),
"Fmax": self._upper_limit_force(),
}
class AmesimLstp00a(AlgebraicComponent):
"""AMESim LSTP00A first public elastic contact component."""
MODEL_TYPE = "amesim_lstp00a"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.mechanical_translational("port_1"),
PortDefinition.mechanical_translational("port_2"),
)
PARAMETERS = (
ParameterDefinition("na", 10.0, label="有效圈数", quantity="dimensionless", unit="", minimum=0.0, minimum_exclusive=True),
ParameterDefinition("gap0", 0.0, label="初始间隙", quantity="length", unit="m"),
ParameterDefinition("kcont", 1.0e6, label="接触刚度", quantity="translational_stiffness", unit="N/m", minimum=0.0),
ParameterDefinition("G", 8.57e10, label="剪切模量", quantity="pressure", unit="Pa", minimum=0.0),
ParameterDefinition("sdiam", 0.02, label="弹簧直径", quantity="length", unit="m", minimum=0.0),
ParameterDefinition("wdiam", 0.002, label="线径", quantity="length", unit="m", minimum=0.0),
ParameterDefinition("rcont", 0.0, label="接触阻尼", quantity="translational_damping", unit="N/(m/s)", minimum=0.0),
ParameterDefinition("Pdis", 1.0e-7, label="满阻尼穿透", quantity="length", unit="m", minimum=0.0),
ParameterDefinition("stiffmode", 1.0, label="刚度模式", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition("discContactOption", 1.0, label="接触选项", quantity="dimensionless", unit="", minimum=0.0),
)
RESULT_VARIABLES = (
ResultVariableDefinition("gap", "间隙", "length", "m", "derived", 10),
ResultVariableDefinition("penetration", "穿透", "length", "m", "derived", 20),
ResultVariableDefinition("force", "接触力", "force", "N", "derived", 30),
)
DISPLAY = ComponentDisplaySpec(
label="LSTP00A 弹性接触",
library_id="amesim",
category_id="mechanical",
symbol="generic",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=40,
)
def __init__(self, name: str, medium: IdealGasMedium, **parameters: float) -> None:
super().__init__(name=name)
resolved = {
definition.name: parameters.get(definition.name, definition.default)
for definition in self.PARAMETERS
}
self.set_parameter_values(resolved)
for name, value in resolved.items():
setattr(self, name, float(value))
self.port_1 = self.register_declared_port("port_1")
self.port_2 = self.register_declared_port("port_2")
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimLstp00a":
for integer_name in ("stiffmode", "discContactOption"):
if not float(parameters[integer_name]).is_integer():
raise ValueError(f"LSTP00A {integer_name} must be an integer.")
return cls(name=name, medium=medium, **dict(parameters))
@property
def gap(self) -> float:
return self.gap0 - (self.port_2.x - self.port_1.x)
@property
def penetration(self) -> float:
return max(-self.gap, 0.0)
@property
def penetration_velocity(self) -> float:
return self.port_2.v - self.port_1.v
@property
def contact_force(self) -> float:
if self.penetration <= 0.0:
return 0.0
return max(self.kcont * self.penetration + self.rcont * self.penetration_velocity, 0.0)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
force = self.contact_force
return (
EquationResidual(
id=f"{self.name}:port_1_contact_force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_1.f", f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
role="flow",
value=self.port_1.f + force,
),
EquationResidual(
id=f"{self.name}:port_2_contact_force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.port_1.x", f"{self.name}.port_2.x"),
role="flow",
value=self.port_2.f - force,
),
)
def component_result_values(self) -> Mapping[str, float]:
return {
"gap": self.gap,
"penetration": self.penetration,
"force": self.contact_force,
}
class AmesimLmechn1(AlgebraicComponent):
"""AMESim LMECHN1 first public dynamic linear mechanical node."""
MODEL_TYPE = "amesim_lmechn1"
MODEL_VERSION = "0.1.0"
PORTS = tuple(
PortDefinition.mechanical_translational(f"port_{index}")
for index in range(1, 10)
)
PARAMETERS = (
ParameterDefinition("v1", 8.0, label="右侧端口数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
ParameterDefinition("sum", 1.0, label="节点求和模式", quantity="dimensionless", unit="", minimum=0.0),
)
RESULT_VARIABLES = (
ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10),
)
DISPLAY = ComponentDisplaySpec(
label="LMECHN1 线性机械节点",
library_id="amesim",
category_id="mechanical",
symbol="junction",
ports=tuple(
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)]
+ [PortDisplaySpec("port_9", "right", order=90)]
),
order=50,
)
def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 8.0, sum: float = 1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({"v1": v1, "sum": sum})
self.v1 = int(v1)
self.sum = int(sum)
for definition in self.PORTS:
setattr(self, definition.name, self.register_declared_port(definition.name))
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimLmechn1":
for integer_name in ("v1", "sum"):
if not float(parameters[integer_name]).is_integer():
raise ValueError(f"LMECHN1 {integer_name} must be an integer.")
return cls(name=name, medium=medium, v1=parameters["v1"], sum=parameters["sum"])
@property
def active_ports(self) -> tuple[str, ...]:
return tuple(f"port_{index}" for index in range(1, self.v1 + 1)) + ("port_9",)
@property
def total_force(self) -> float:
return sum(self.get_port(port_name).f for port_name in self.active_ports)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
reference = self.port_9
residuals: list[EquationResidual] = []
for port_name in self.active_ports[:-1]:
port = self.get_port(port_name)
residuals.append(
EquationResidual(
id=f"{self.name}:{port_name}_x_equal",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.{port_name}.x", f"{self.name}.port_9.x"),
role="effort",
value=port.x - reference.x,
)
)
residuals.append(
EquationResidual(
id=f"{self.name}:{port_name}_v_equal",
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.{port_name}.v", f"{self.name}.port_9.v"),
role="effort",
value=port.v - reference.v,
)
)
residuals.append(
EquationResidual(
id=f"{self.name}:force_balance",
owner="component",
owner_id=self.name,
relation="sumToZero",
variables=tuple(f"{self.name}.{port_name}.f" for port_name in self.active_ports),
role="flow",
value=self.total_force,
)
)
return tuple(residuals)
def component_result_values(self) -> Mapping[str, float]:
return {"tforce": self.total_force}
Whitespace-only changes.
@@ -0,0 +1,204 @@
from __future__ import annotations
from collections.abc import Mapping
from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
class AmesimStep0(AlgebraicComponent):
"""AMESim STEP0 scalar step signal source."""
MODEL_TYPE = "amesim_step0"
MODEL_VERSION = "0.1.0"
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
PARAMETERS = (
ParameterDefinition("initial", 0.0, label="初始值", quantity="dimensionless", unit=""),
ParameterDefinition("final", 1.0, label="阶跃后值", quantity="dimensionless", unit=""),
ParameterDefinition("time", 0.0, label="阶跃时间", quantity="time", unit="s"),
)
RESULT_VARIABLES = (
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
)
DISPLAY = ComponentDisplaySpec(
label="STEP0 阶跃信号",
library_id="amesim",
category_id="signals",
symbol="signal",
ports=(PortDisplaySpec("out", "right", order=10),),
order=10,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
initial: float = 0.0,
final: float = 1.0,
time: float = 0.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values({"initial": initial, "final": final, "time": time})
self.initial = float(initial)
self.final = float(final)
self.time = float(time)
self.out = self.register_declared_port("out")
self.out.signal = self.output_at(0.0)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimStep0":
return cls(
name=name,
medium=medium,
initial=parameters["initial"],
final=parameters["final"],
time=parameters["time"],
)
def output_at(self, time: float) -> float:
return self.final if time >= self.time else self.initial
def signal_output_values(self, time: float) -> dict[str, float]:
return {"out": self.output_at(time)}
def component_result_values(self) -> Mapping[str, float]:
return {"y": self.out.signal}
class AmesimUd00(AlgebraicComponent):
"""AMESim UD00 piecewise-linear scalar signal source."""
MODEL_TYPE = "amesim_ud00"
MODEL_VERSION = "0.1.0"
PORTS = (PortDefinition.signal("out", nominal_role="output"),)
PARAMETERS = (
ParameterDefinition("tstart", 0.0, label="启动时间", quantity="time", unit="s"),
ParameterDefinition("start1", 0.0, label="第 1 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end1", 1.0, label="第 1 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t1", 1.0, label="第 1 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start2", 1.0, label="第 2 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end2", 1.0, label="第 2 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t2", 0.0, label="第 2 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start3", 1.0, label="第 3 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end3", 1.0, label="第 3 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t3", 0.0, label="第 3 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start4", 1.0, label="第 4 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end4", 1.0, label="第 4 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t4", 0.0, label="第 4 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start5", 1.0, label="第 5 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end5", 1.0, label="第 5 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t5", 0.0, label="第 5 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start6", 1.0, label="第 6 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end6", 1.0, label="第 6 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t6", 0.0, label="第 6 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start7", 1.0, label="第 7 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end7", 1.0, label="第 7 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t7", 0.0, label="第 7 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("start8", 1.0, label="第 8 段起点", quantity="dimensionless", unit=""),
ParameterDefinition("end8", 1.0, label="第 8 段终点", quantity="dimensionless", unit=""),
ParameterDefinition("t8", 0.0, label="第 8 段时长", quantity="time", unit="s", minimum=0.0),
ParameterDefinition("nstages", 1.0, label="段数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
ParameterDefinition("iscyclic", 0.0, label="循环", quantity="dimensionless", unit="", minimum=0.0, maximum=1.0),
)
RESULT_VARIABLES = (
ResultVariableDefinition("y", "输出", "dimensionless", "", "signal", 10),
)
DISPLAY = ComponentDisplaySpec(
label="UD00 分段线性信号",
library_id="amesim",
category_id="signals",
symbol="signal",
ports=(PortDisplaySpec("out", "right", order=10),),
order=20,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
tstart: float = 0.0,
starts: tuple[float, ...] = (0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0),
ends: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0),
durations: tuple[float, ...] = (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
nstages: int = 1,
iscyclic: bool = False,
) -> None:
super().__init__(name=name)
if len(starts) != 8 or len(ends) != 8 or len(durations) != 8:
raise ValueError("UD00 requires exactly eight start, end, and duration values.")
if nstages < 1 or nstages > 8:
raise ValueError("UD00 nstages must be between 1 and 8.")
self.tstart = float(tstart)
self.starts = tuple(float(value) for value in starts)
self.ends = tuple(float(value) for value in ends)
self.durations = tuple(float(value) for value in durations)
self.nstages = int(nstages)
self.iscyclic = bool(iscyclic)
values: dict[str, float] = {"tstart": self.tstart, "nstages": float(self.nstages), "iscyclic": float(int(self.iscyclic))}
for index in range(1, 9):
values[f"start{index}"] = self.starts[index - 1]
values[f"end{index}"] = self.ends[index - 1]
values[f"t{index}"] = self.durations[index - 1]
self.set_parameter_values(values)
self.out = self.register_declared_port("out")
self.out.signal = self.output_at(0.0)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimUd00":
nstages = parameters["nstages"]
iscyclic = parameters["iscyclic"]
if not float(nstages).is_integer():
raise ValueError("UD00 nstages must be an integer.")
if not float(iscyclic).is_integer():
raise ValueError("UD00 iscyclic must be 0 or 1.")
return cls(
name=name,
medium=medium,
tstart=parameters["tstart"],
starts=tuple(parameters[f"start{index}"] for index in range(1, 9)),
ends=tuple(parameters[f"end{index}"] for index in range(1, 9)),
durations=tuple(parameters[f"t{index}"] for index in range(1, 9)),
nstages=int(nstages),
iscyclic=bool(int(iscyclic)),
)
def output_at(self, time: float) -> float:
elapsed = max(float(time) - self.tstart, 0.0)
active_durations = self.durations[: self.nstages]
total_duration = sum(active_durations)
if self.iscyclic and total_duration > 0.0:
elapsed = elapsed % total_duration
stage_start_time = 0.0
for index, duration in enumerate(active_durations):
stage_end_time = stage_start_time + duration
if elapsed < stage_end_time or index == self.nstages - 1:
if duration <= 0.0:
return self.ends[index]
fraction = (elapsed - stage_start_time) / duration
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
stage_start_time = stage_end_time
return self.ends[self.nstages - 1]
def signal_output_values(self, time: float) -> dict[str, float]:
return {"out": self.output_at(time)}
def component_result_values(self) -> Mapping[str, float]:
return {"y": self.out.signal}
@@ -0,0 +1 @@
"""AMESim pneumatic storage components."""
@@ -0,0 +1,521 @@
from __future__ import annotations
from collections.abc import Mapping
from math import isclose
from app.simulation.core.base import ThermodynamicVolumeComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.equations import EquationResidual
from app.simulation.core.metadata import (
ParameterDefinition,
ResultVariableDefinition,
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
)
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
from app.simulation.core.ports import PortDefinition
from app.simulation.core.state import VolumeState
class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
The AMESim submodel owns pressure and temperature states and exposes two
pneumatic flow ports. This public component maps those states onto the
framework's mass/internal-energy volume state and keeps the AMESim
heat-transfer contract `kth * sth * (extemp - T)`.
"""
MODEL_TYPE = "amesim_pnch023"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
"cvol",
0.057,
label="气室容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"kth",
0.0,
label="换热系数",
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
),
ParameterDefinition(
"sth",
0.1,
label="换热面积",
quantity="area",
unit="m2",
minimum=0.0,
),
ParameterDefinition(
"extemp",
293.15,
label="外部温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
),
ParameterDefinition(
"p0",
100000.0,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
293.15,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(
label="PNCH023 固定容积气室",
library_id="amesim",
category_id="storage",
symbol="tank",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
),
order=10,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cvol: float = 0.057,
kth: float = 0.0,
sth: float = 0.1,
extemp: float = 293.15,
gi: float = 1.0,
p0: float = 100000.0,
T0: float = 293.15,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"cvol": cvol,
"kth": kth,
"sth": sth,
"extemp": extemp,
"gi": gi,
"p0": p0,
"T0": T0,
}
)
self.medium = medium
self.cvol = float(cvol)
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = self._integer_parameter("gi", gi)
self.p0 = float(p0)
self.T0 = float(T0)
m0 = self.p0 * self.cvol / (medium.R_gas * self.T0)
U0 = m0 * medium.specific_internal_energy(self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
self.port_1 = self.register_declared_port("port_1")
self.port_1.p = self.p0
self.port_1.h_outflow = initial_h
self.port_2 = self.register_declared_port("port_2")
self.port_2.p = self.p0
self.port_2.h_outflow = initial_h
@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,
parameters: Mapping[str, float],
) -> AmesimPnch023:
return cls(
name=name,
medium=medium,
cvol=parameters["cvol"],
kth=parameters["kth"],
sth=parameters["sth"],
extemp=parameters["extemp"],
gi=parameters["gi"],
p0=parameters["p0"],
T0=parameters["T0"],
)
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.cvol)
self.port_1.p = props.p
self.port_1.h_outflow = props.h
self.port_2.p = props.p
self.port_2.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def thermal_energy_flow_w(self, temperature: float) -> float:
return self.kth * self.sth * (self.extemp - temperature)
def state_derivative_from_ports(
self,
connected_h: Mapping[str, float],
) -> list[float]:
props = self.properties()
inlet_h_1 = self.connection_inlet_enthalpy(
port_m_flow=self.port_1.m_flow,
connected_h=connected_h["port_1"],
internal_h=props.h,
)
inlet_h_2 = self.connection_inlet_enthalpy(
port_m_flow=self.port_2.m_flow,
connected_h=connected_h["port_2"],
internal_h=props.h,
)
derivative = VolumeState(
m=self.port_1.m_flow + self.port_2.m_flow,
U=(
self.port_1.m_flow * inlet_h_1
+ self.port_2.m_flow * inlet_h_2
+ self.thermal_energy_flow_w(props.T)
),
)
return derivative.as_vector()
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.cvol,
).p
return (
EquationResidual(
id=f"{self.name}:port_1_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_1.p", f"{self.name}.state"),
role="effort",
value=self.port_1.p - pressure,
),
EquationResidual(
id=f"{self.name}:port_2_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.port_2.p", f"{self.name}.state"),
role="effort",
value=self.port_2.p - pressure,
),
)
class AmesimPnch012(ThermodynamicVolumeComponent):
"""AMESim PNCH012 variable-volume pneumatic chamber.
AMESim supplies four external volume and volume-rate inputs through the
chamber ports. The current public System XML contract has pneumatic ports
only, so this first public model exposes those external volume inputs as SI
parameters. This represents fixed or prescribed-volume PNCH012 cases and is
not yet the full mechanical-coupled submodel.
"""
MODEL_TYPE = "amesim_pnch012"
MODEL_VERSION = "0.1.0"
PORTS = (
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_3", nominal_role="bidirectional"),
PortDefinition.pneumatic("port_4", nominal_role="bidirectional"),
)
PARAMETERS = (
ParameterDefinition(
"cvol0",
0.015,
label="死容积",
quantity="volume",
unit="m3",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"kth",
0.0,
label="换热系数",
quantity="heat_transfer_coefficient",
unit="W/(m2*K)",
minimum=0.0,
),
ParameterDefinition(
"sth",
0.1,
label="换热面积",
quantity="area",
unit="m2",
minimum=0.0,
),
ParameterDefinition(
"extemp",
293.15,
label="外部温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"gi",
1.0,
label="气体类型索引",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=99.0,
),
ParameterDefinition(
"p0",
100000.0,
label="初始压力",
quantity="pressure",
unit="Pa",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition(
"T0",
293.15,
label="初始温度",
quantity="temperature",
unit="K",
minimum=0.0,
minimum_exclusive=True,
),
ParameterDefinition("vol1", 0.0, label="端口 1 外部容积", quantity="volume", unit="m3"),
ParameterDefinition("vol2", 0.0, label="端口 2 外部容积", quantity="volume", unit="m3"),
ParameterDefinition("vol3", 0.0, label="端口 3 外部容积", quantity="volume", unit="m3"),
ParameterDefinition("vol4", 0.0, label="端口 4 外部容积", quantity="volume", unit="m3"),
ParameterDefinition("dvol1", 0.0, label="端口 1 容积变化率", quantity="volume_flow", unit="m3/s"),
ParameterDefinition("dvol2", 0.0, label="端口 2 容积变化率", quantity="volume_flow", unit="m3/s"),
ParameterDefinition("dvol3", 0.0, label="端口 3 容积变化率", quantity="volume_flow", unit="m3/s"),
ParameterDefinition("dvol4", 0.0, label="端口 4 容积变化率", quantity="volume_flow", unit="m3/s"),
)
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (
ResultVariableDefinition("vol", "气室总容积", "volume", "m3", "derived", 100),
ResultVariableDefinition("dvol", "总容积变化率", "volume_flow", "m3/s", "derived", 110),
)
DISPLAY = ComponentDisplaySpec(
label="PNCH012 变容气室",
library_id="amesim",
category_id="storage",
symbol="tank",
ports=(
PortDisplaySpec("port_1", "left", order=10),
PortDisplaySpec("port_2", "right", order=20),
PortDisplaySpec("port_3", "left", order=30),
PortDisplaySpec("port_4", "right", order=40),
),
order=20,
)
def __init__(
self,
name: str,
medium: IdealGasMedium,
*,
cvol0: float = 0.015,
kth: float = 0.0,
sth: float = 0.1,
extemp: float = 293.15,
gi: float = 1.0,
p0: float = 100000.0,
T0: float = 293.15,
vol1: float = 0.0,
vol2: float = 0.0,
vol3: float = 0.0,
vol4: float = 0.0,
dvol1: float = 0.0,
dvol2: float = 0.0,
dvol3: float = 0.0,
dvol4: float = 0.0,
) -> None:
super().__init__(name=name)
self.set_parameter_values(
{
"cvol0": cvol0,
"kth": kth,
"sth": sth,
"extemp": extemp,
"gi": gi,
"p0": p0,
"T0": T0,
"vol1": vol1,
"vol2": vol2,
"vol3": vol3,
"vol4": vol4,
"dvol1": dvol1,
"dvol2": dvol2,
"dvol3": dvol3,
"dvol4": dvol4,
}
)
self.medium = medium
self.cvol0 = float(cvol0)
self.kth = float(kth)
self.sth = float(sth)
self.extemp = float(extemp)
self.gi = AmesimPnch023._integer_parameter("gi", gi)
self.p0 = float(p0)
self.T0 = float(T0)
self.external_volumes = {
"port_1": float(vol1),
"port_2": float(vol2),
"port_3": float(vol3),
"port_4": float(vol4),
}
self.external_volume_rates = {
"port_1": float(dvol1),
"port_2": float(dvol2),
"port_3": float(dvol3),
"port_4": float(dvol4),
}
if self.total_volume() <= 0.0:
raise ValueError("PNCH012 total volume must be positive.")
m0 = self.p0 * self.total_volume() / (medium.R_gas * self.T0)
U0 = m0 * medium.specific_internal_energy(self.T0)
self.state = VolumeState(m=m0, U=U0)
initial_h = medium.specific_enthalpy(self.T0)
for port_name in ("port_1", "port_2", "port_3", "port_4"):
port = self.register_declared_port(port_name)
port.p = self.p0
port.h_outflow = initial_h
setattr(self, port_name, port)
@classmethod
def create(
cls,
*,
name: str,
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimPnch012":
return cls(name=name, medium=medium, **dict(parameters))
def total_volume(self) -> float:
minimum_volume = self.cvol0 / 100.0
return max(self.cvol0 + sum(self.external_volumes.values()), minimum_volume)
def total_volume_rate(self) -> float:
if self.total_volume() <= self.cvol0 / 100.0:
return 0.0
return sum(self.external_volume_rates.values())
def get_state_vector(self) -> list[float]:
return self.state.as_vector()
def set_state_vector(self, values: list[float]) -> None:
self.state = VolumeState.from_vector(values)
def properties(self) -> ThermodynamicProperties:
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.total_volume())
for port_name in ("port_1", "port_2", "port_3", "port_4"):
port = self.get_port(port_name)
port.p = props.p
port.h_outflow = props.h
return props
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
return self.properties()
def thermal_energy_flow_w(self, temperature: float) -> float:
return self.kth * self.sth * (self.extemp - temperature)
def component_result_values(self) -> Mapping[str, float]:
props = self.properties()
return {
"m": self.state.m,
"U": self.state.U,
"p": props.p,
"T": props.T,
"rho": props.rho,
"u": props.u,
"h": props.h,
"vol": self.total_volume(),
"dvol": self.total_volume_rate(),
}
def state_derivative_from_ports(self, connected_h: Mapping[str, float]) -> list[float]:
props = self.properties()
mass_derivative = 0.0
energy_derivative = 0.0
for port_name in ("port_1", "port_2", "port_3", "port_4"):
port = self.get_port(port_name)
inlet_h = self.connection_inlet_enthalpy(
port_m_flow=port.m_flow,
connected_h=connected_h[port_name],
internal_h=props.h,
)
mass_derivative += port.m_flow
energy_derivative += port.m_flow * inlet_h
energy_derivative += self.thermal_energy_flow_w(props.T)
energy_derivative -= props.p * self.total_volume_rate()
return VolumeState(m=mass_derivative, U=energy_derivative).as_vector()
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
pressure = self.medium.properties_from_mU(
self.state.m,
self.state.U,
self.total_volume(),
).p
return tuple(
EquationResidual(
id=f"{self.name}:{port_name}_pressure_state",
owner="component",
owner_id=self.name,
relation="state",
variables=(f"{self.name}.{port_name}.p", f"{self.name}.state"),
role="effort",
value=self.get_port(port_name).p - pressure,
)
for port_name in ("port_1", "port_2", "port_3", "port_4")
)
+10
View File
@@ -9,9 +9,13 @@ ResultVariableScope = Literal["component", "port"]
SI_UNIT_BY_QUANTITY: dict[str, str] = {
"acceleration": "m/s2",
"area": "m2",
"dimensionless": "",
"density": "kg/m³",
"flow_coefficient": "kg/(s*Pa^0.5)",
"force": "N",
"heat_transfer_coefficient": "W/(m2*K)",
"internal_energy": "J",
"length": "m",
"mass": "kg",
@@ -20,7 +24,13 @@ SI_UNIT_BY_QUANTITY: dict[str, str] = {
"specific_enthalpy": "J/kg",
"specific_internal_energy": "J/kg",
"temperature": "K",
"translational_damping": "N/(m/s)",
"translational_stiffness": "N/m",
"time": "s",
"velocity": "m/s",
"volume": "m3",
"volume_flow": "m3/s",
"windage": "N/(m/s)^2",
}
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
from dataclasses import dataclass
from math import acos, cos, isfinite, log, pi, sqrt
UNIVERSAL_GAS_CONSTANT = 8.31446261815324
@dataclass(frozen=True)
class PengRobinsonFluid:
"""Pure-fluid Peng-Robinson equation-of-state helper.
The class covers the equation-of-state layer plus the enthalpy departure
needed to compare AMESim pneumatic ``pn2hpti`` reference enthalpy flows.
"""
name: str
molar_mass: float
critical_temperature: float
critical_pressure: float
acentric_factor: float
@property
def specific_gas_constant(self) -> float:
return UNIVERSAL_GAS_CONSTANT / self.molar_mass
@property
def a_parameter(self) -> float:
return (
0.45724
* UNIVERSAL_GAS_CONSTANT
* UNIVERSAL_GAS_CONSTANT
* self.critical_temperature
* self.critical_temperature
/ self.critical_pressure
)
@property
def b_parameter(self) -> float:
return 0.07780 * UNIVERSAL_GAS_CONSTANT * self.critical_temperature / self.critical_pressure
@property
def kappa(self) -> float:
omega = self.acentric_factor
return 0.37464 + 1.54226 * omega - 0.26992 * omega * omega
def alpha(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
return (1.0 + self.kappa * (1.0 - sqrt(reduced_temperature))) ** 2.0
def alpha_temperature_derivative(self, temperature: float) -> float:
self._validate_temperature(temperature)
reduced_temperature = temperature / self.critical_temperature
sqrt_reduced_temperature = sqrt(reduced_temperature)
alpha_base = 1.0 + self.kappa * (1.0 - sqrt_reduced_temperature)
return -(
alpha_base
* self.kappa
/ (self.critical_temperature * sqrt_reduced_temperature)
)
def attractive_parameter(self, temperature: float) -> float:
return self.a_parameter * self.alpha(temperature)
def attractive_parameter_temperature_derivative(self, temperature: float) -> float:
return self.a_parameter * self.alpha_temperature_derivative(temperature)
def pressure_from_molar_volume(self, temperature: float, molar_volume: float) -> float:
self._validate_temperature(temperature)
if molar_volume <= self.b_parameter:
raise ValueError("Molar volume must be larger than Peng-Robinson b parameter.")
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
repulsive = UNIVERSAL_GAS_CONSTANT * temperature / (molar_volume - b)
attractive = a_alpha / (molar_volume * (molar_volume + b) + b * (molar_volume - b))
return repulsive - attractive
def pressure_from_density(self, temperature: float, density: float) -> float:
if density <= 0.0:
raise ValueError("Density must be positive.")
return self.pressure_from_molar_volume(temperature, self.molar_mass / density)
def reduced_parameters(self, pressure: float, temperature: float) -> tuple[float, float]:
self._validate_pressure_temperature(pressure, temperature)
a_alpha = self.attractive_parameter(temperature)
b = self.b_parameter
A = a_alpha * pressure / (UNIVERSAL_GAS_CONSTANT * UNIVERSAL_GAS_CONSTANT * temperature * temperature)
B = b * pressure / (UNIVERSAL_GAS_CONSTANT * temperature)
return A, B
def compressibility_roots(self, pressure: float, temperature: float) -> tuple[float, ...]:
A, B = self.reduced_parameters(pressure, temperature)
coefficients = (
-(1.0 - B),
A - 3.0 * B * B - 2.0 * B,
-(A * B - B * B - B * B * B),
)
roots = _real_cubic_roots(*coefficients)
physical_roots = tuple(sorted(root for root in roots if root > B and isfinite(root)))
if not physical_roots:
raise ValueError("Peng-Robinson cubic produced no physical compressibility root.")
return physical_roots
def compressibility_factor(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
roots = self.compressibility_roots(pressure, temperature)
if phase == "vapor":
return roots[-1]
if phase == "liquid":
return roots[0]
if phase == "stable-single-root":
return roots[-1]
raise ValueError(f"Unsupported phase selector: {phase!r}")
def molar_volume(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
z = self.compressibility_factor(pressure, temperature, phase=phase)
return z * UNIVERSAL_GAS_CONSTANT * temperature / pressure
def density(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
return self.molar_mass / self.molar_volume(pressure, temperature, phase=phase)
def residual_specific_enthalpy(
self,
pressure: float,
temperature: float,
phase: str = "vapor",
) -> float:
"""Return Peng-Robinson enthalpy departure from ideal gas, J/kg."""
self._validate_pressure_temperature(pressure, temperature)
z = self.compressibility_factor(pressure, temperature, phase=phase)
_, B = self.reduced_parameters(pressure, temperature)
b = self.b_parameter
attractive = self.attractive_parameter(temperature)
d_attractive_d_temperature = (
self.attractive_parameter_temperature_derivative(temperature)
)
log_argument = (z + (1.0 + sqrt(2.0)) * B) / (
z + (1.0 - sqrt(2.0)) * B
)
residual_molar_enthalpy = (
UNIVERSAL_GAS_CONSTANT * temperature * (z - 1.0)
+ (
temperature * d_attractive_d_temperature
- attractive
)
* log(log_argument)
/ (2.0 * sqrt(2.0) * b)
)
return residual_molar_enthalpy / self.molar_mass
@staticmethod
def _validate_temperature(temperature: float) -> None:
if temperature <= 0.0:
raise ValueError("Temperature must be positive.")
@classmethod
def _validate_pressure_temperature(cls, pressure: float, temperature: float) -> None:
if pressure <= 0.0:
raise ValueError("Pressure must be positive.")
cls._validate_temperature(temperature)
HELIUM_PR = PengRobinsonFluid(
name="helium",
molar_mass=0.004002602,
critical_temperature=5.1953,
critical_pressure=227_460.0,
acentric_factor=-0.385,
)
NITROGEN_PR = PengRobinsonFluid(
name="nitrogen",
molar_mass=0.0280134,
critical_temperature=126.192,
critical_pressure=3.3958e6,
acentric_factor=0.0372,
)
AIR_PR = PengRobinsonFluid(
name="air",
molar_mass=0.02896513,
critical_temperature=132.5306,
critical_pressure=3.786e6,
acentric_factor=0.0335,
)
def _real_cubic_roots(a: float, b: float, c: float) -> tuple[float, ...]:
"""Return real roots for x**3 + a*x**2 + b*x + c = 0."""
depressed_p = b - a * a / 3.0
depressed_q = 2.0 * a * a * a / 27.0 - a * b / 3.0 + c
discriminant = (depressed_q / 2.0) ** 2.0 + (depressed_p / 3.0) ** 3.0
offset = -a / 3.0
tolerance = 1e-14
if discriminant > tolerance:
sqrt_discriminant = sqrt(discriminant)
u = _real_cube_root(-depressed_q / 2.0 + sqrt_discriminant)
v = _real_cube_root(-depressed_q / 2.0 - sqrt_discriminant)
return (u + v + offset,)
if abs(discriminant) <= tolerance:
u = _real_cube_root(-depressed_q / 2.0)
return tuple(sorted({2.0 * u + offset, -u + offset}))
if depressed_p >= 0.0:
raise ValueError("Unexpected cubic state with three real roots and non-negative p.")
radius = 2.0 * sqrt(-depressed_p / 3.0)
argument = (3.0 * depressed_q / (2.0 * depressed_p)) * sqrt(-3.0 / depressed_p)
argument = max(-1.0, min(1.0, argument))
theta = acos(argument) / 3.0
roots = [
radius * cos(theta - 2.0 * pi * index / 3.0) + offset
for index in range(3)
]
return tuple(sorted(roots))
def _real_cube_root(value: float) -> float:
if value == 0.0:
return 0.0
return (1.0 if value > 0.0 else -1.0) * abs(value) ** (1.0 / 3.0)
+74
View File
@@ -90,6 +90,76 @@ class PortDefinition:
),
)
@classmethod
def mechanical_translational(
cls,
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
) -> PortDefinition:
return cls(
name=name,
kind="physical",
domain="mechanical",
nominal_role=nominal_role,
positive_flow_direction="intoComponent",
variables=(
PortVariableDefinition(
"x",
"effort",
"equal",
label="位移",
quantity="length",
unit="m",
order=10,
),
PortVariableDefinition(
"v",
"effort",
"equal",
label="速度",
quantity="velocity",
unit="m/s",
order=20,
),
PortVariableDefinition(
"f",
"flow",
"sumToZero",
label="力",
quantity="force",
unit="N",
order=30,
),
),
)
@classmethod
def signal(
cls,
name: str,
*,
nominal_role: Literal["input", "output"],
domain: str = "signal",
) -> PortDefinition:
return cls(
name=name,
kind="signal",
domain=domain,
nominal_role=nominal_role,
variables=(
PortVariableDefinition(
"signal",
"signal",
"directed",
label="信号值",
quantity="dimensionless",
unit="",
order=10,
),
),
)
def as_interface_dict(self) -> dict[str, object]:
return {
"name": self.name,
@@ -108,6 +178,10 @@ class PortState:
p: float = 0.0
m_flow: float = 0.0
h_outflow: float = 0.0
signal: float = 0.0
x: float = 0.0
v: float = 0.0
f: float = 0.0
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
@classmethod
@@ -0,0 +1,17 @@
from __future__ import annotations
from app.simulation.examples.test_mql.run import (
PreparedTestMqlRun,
TestMqlRunResult,
prepare_test_mql_run,
run_prepared_test_mql,
run_test_mql,
)
__all__ = [
"PreparedTestMqlRun",
"TestMqlRunResult",
"prepare_test_mql_run",
"run_prepared_test_mql",
"run_test_mql",
]
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
from PythonModels.scripts.run_test_mql import format_test_mql_summary
from PythonModels.systems.test_mql import (
TestMqlRunConfig,
TestMqlSimulationResult,
TestMqlSystem,
)
@dataclass(frozen=True)
class PreparedTestMqlRun:
run_config: TestMqlRunConfig
repo_root: Path
output_dir: Path
@dataclass(frozen=True)
class TestMqlRunResult:
run_config: TestMqlRunConfig
prepared_run: PreparedTestMqlRun
system: TestMqlSystem
result: TestMqlSimulationResult
summary_path: Path
def _default_run_output_dir() -> Path:
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
return SIMULATION_RUNS_DIR / timestamp
def prepare_test_mql_run(
*,
run_config: TestMqlRunConfig | None = None,
output_dir: Path | None = None,
) -> PreparedTestMqlRun:
return PreparedTestMqlRun(
run_config=run_config or TestMqlRunConfig(),
repo_root=PROJECT_ROOT,
output_dir=output_dir or _default_run_output_dir(),
)
def run_prepared_test_mql(prepared_run: PreparedTestMqlRun) -> TestMqlRunResult:
system = TestMqlSystem()
result = system.simulate(prepared_run.run_config)
prepared_run.output_dir.mkdir(parents=True, exist_ok=True)
summary_path = prepared_run.output_dir / "test_mql_model_summary.txt"
summary_path.write_text(format_test_mql_summary(system), encoding="utf-8")
return TestMqlRunResult(
run_config=prepared_run.run_config,
prepared_run=prepared_run,
system=system,
result=result,
summary_path=summary_path,
)
def run_test_mql(
*,
run_config: TestMqlRunConfig | None = None,
output_dir: Path | None = None,
) -> TestMqlRunResult:
return run_prepared_test_mql(
prepare_test_mql_run(run_config=run_config, output_dir=output_dir)
)
def main() -> None:
run = run_test_mql()
print(format_test_mql_summary(run.system), end="")
print(f"Samples: {len(run.result.t)}")
print(f"Output directory: {run.prepared_run.output_dir}")
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
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()
@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.systems.test_mql_baseline import (
TestMqlBaselineRun,
run_test_mql_baseline_passthrough,
)
@dataclass(frozen=True)
class TestMqlBaselinePathConfig:
archive_path: Path = field(
default_factory=lambda: Path(__file__).resolve().parents[2] / "AmesimModels" / "test_mql.ame"
)
output_dir: Path | None = None
@dataclass(frozen=True)
class TestMqlBaselineExecutionConfig:
write_summary: bool = True
data_paths: tuple[str, ...] | None = None
@dataclass(frozen=True)
class TestMqlBaselineScriptConfig:
paths: TestMqlBaselinePathConfig = field(default_factory=TestMqlBaselinePathConfig)
execution: TestMqlBaselineExecutionConfig = field(default_factory=TestMqlBaselineExecutionConfig)
def _default_output_dir() -> Path:
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
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
return "\n".join(
[
"Model: test_mql",
"Mode: AMESim baseline passthrough",
f"Samples: {run.sample_count}",
f"Output schema signals: {run.output_schema.signal_count}",
f"Compared signals: {run.signal_count}",
f"Observation bindings: {run.observation_catalog.binding_count}",
f"Max absolute error: {run.comparison.max_abs_error}",
f"Max relative error: {run.comparison.max_rel_error}",
]
) + "\n"
def run_test_mql_baseline(config: TestMqlBaselineScriptConfig | None = None):
config = config or TestMqlBaselineScriptConfig()
run = run_test_mql_baseline_passthrough(
config.paths.archive_path,
data_paths=config.execution.data_paths,
)
output_dir = config.paths.output_dir or _default_output_dir()
if config.execution.write_summary:
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "test_mql_baseline_summary.txt").write_text(
format_test_mql_baseline_summary(run),
encoding="utf-8",
)
return run, output_dir
def main() -> None:
run, output_dir = run_test_mql_baseline()
print(format_test_mql_baseline_summary(run), end="")
print(f"Output directory: {output_dir}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,77 @@
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 (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
)
@dataclass(frozen=True)
class TestMqlBaselineRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
def run_test_mql_baseline_passthrough(
archive_path: Path,
*,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> TestMqlBaselineRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
selected_paths = tuple(data_paths) if data_paths is not None else output_schema.data_paths()
baseline_series = observation_catalog.baseline_series_by_data_path(
amesim_results,
selected_paths,
)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=baseline_series,
schema=output_schema,
data_paths=selected_paths,
require_all_schema_paths=data_paths is None,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
require_all_schema_paths=data_paths is None,
)
return TestMqlBaselineRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
output=output,
comparison=comparison,
)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,468 @@
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 (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.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 (
TestMqlMechanicalAssembly,
build_test_mql_mechanical_assembly,
)
from PythonModels.systems.test_mql_pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@dataclass(frozen=True)
class TestMqlComputedPistonGeometryRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
mechanical_assembly: TestMqlMechanicalAssembly
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedGeometryRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
mechanical_assembly: TestMqlMechanicalAssembly
pneumatic_assembly: TestMqlPneumaticAssembly
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedLineRelationsRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedPneumaticRelationsRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedMechanicalRelationsRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
mechanical_assembly: TestMqlMechanicalAssembly
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
def run_test_mql_computed_piston_geometry(
archive_path: Path,
) -> TestMqlComputedPistonGeometryRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
variable_catalog = build_test_mql_variable_catalog(amesim_results)
mechanical_assembly = build_test_mql_mechanical_assembly(
amesim_results=amesim_results,
variable_catalog=variable_catalog,
)
output_series = _compute_piston_geometry_series(amesim_results, mechanical_assembly)
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedPistonGeometryRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
mechanical_assembly=mechanical_assembly,
output=output,
comparison=comparison,
)
def run_test_mql_computed_geometry(
archive_path: Path,
) -> TestMqlComputedGeometryRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
variable_catalog = build_test_mql_variable_catalog(amesim_results)
mechanical_assembly = build_test_mql_mechanical_assembly(
amesim_results=amesim_results,
variable_catalog=variable_catalog,
)
pneumatic_assembly = build_test_mql_pneumatic_assembly()
output_series = {
**_compute_piston_geometry_series(amesim_results, mechanical_assembly),
**_compute_variable_chamber_volume_series(
amesim_results,
mechanical_assembly,
pneumatic_assembly,
),
}
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedGeometryRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
mechanical_assembly=mechanical_assembly,
pneumatic_assembly=pneumatic_assembly,
output=output,
comparison=comparison,
)
def run_test_mql_computed_line_relations(
archive_path: Path,
) -> TestMqlComputedLineRelationsRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
output_series = _compute_line_reversed_series(amesim_results, observation_catalog)
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedLineRelationsRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
output=output,
comparison=comparison,
)
def run_test_mql_computed_pneumatic_relations(
archive_path: Path,
) -> TestMqlComputedPneumaticRelationsRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
output_series = {
**_compute_chamber_duplicate_series(amesim_results, observation_catalog),
**_compute_orifice_reversed_series(amesim_results, observation_catalog),
}
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedPneumaticRelationsRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
output=output,
comparison=comparison,
)
def run_test_mql_computed_mechanical_relations(
archive_path: Path,
) -> TestMqlComputedMechanicalRelationsRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
variable_catalog = build_test_mql_variable_catalog(amesim_results)
mechanical_assembly = build_test_mql_mechanical_assembly(
amesim_results=amesim_results,
variable_catalog=variable_catalog,
)
output_series = {
**_compute_mass_duplicate_series(amesim_results, mechanical_assembly),
**_compute_inactive_mass_force_series(amesim_results, mechanical_assembly),
**_compute_zero_force_source_series(amesim_results, mechanical_assembly),
}
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedMechanicalRelationsRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
mechanical_assembly=mechanical_assembly,
output=output,
comparison=comparison,
)
def _compute_piston_geometry_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for alias in sorted(mechanical_assembly.pistons):
piston = mechanical_assembly.pistons[alias]
geometry = piston.geometry()
x4 = amesim_results.series(f"x4@{alias}")
x5 = amesim_results.series(f"x5@{alias}")
v4 = amesim_results.series(f"v4@{alias}")
v5 = amesim_results.series(f"v5@{alias}")
series_by_data_path[f"length@{alias}"] = tuple(
geometry.chamber_length_mm(port4, port5)
for port4, port5 in zip(x4, x5)
)
series_by_data_path[f"vol1@{alias}"] = tuple(
geometry.chamber_volume_cm3(port4, port5)
for port4, port5 in zip(x4, x5)
)
series_by_data_path[f"vvol1@{alias}"] = tuple(
geometry.chamber_volume_rate_l_min(port4, port5)
for port4, port5 in zip(v4, v5)
)
return series_by_data_path
def _compute_variable_chamber_volume_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
pneumatic_assembly: TestMqlPneumaticAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for chamber_alias in sorted(pneumatic_assembly.variable_chambers):
chamber = pneumatic_assembly.variable_chambers[chamber_alias]
piston_alias = _piston_alias_for_variable_chamber(chamber_alias)
piston = mechanical_assembly.pistons[piston_alias]
geometry = piston.geometry()
x4 = amesim_results.series(f"x4@{piston_alias}")
x5 = amesim_results.series(f"x5@{piston_alias}")
dead_volume_cm3 = m3_to_cm3(chamber.dead_volume)
series_by_data_path[f"vol@{chamber_alias}"] = tuple(
dead_volume_cm3 + geometry.chamber_volume_cm3(port4, port5)
for port4, port5 in zip(x4, x5)
)
return series_by_data_path
def _piston_alias_for_variable_chamber(chamber_alias: str) -> str:
if not chamber_alias.startswith("pn_c1"):
raise ValueError(f"Unexpected PNCH012 alias: {chamber_alias}")
return chamber_alias.replace("pn_c1", "pn_brp2", 1)
def _compute_mass_duplicate_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for alias in sorted(mechanical_assembly.masses):
for signal_name in ("x1", "v1", "acc1"):
source_path = f"{signal_name}@{alias}"
duplicate_path = f"{signal_name}dup@{alias}"
series_by_data_path[duplicate_path] = tuple(
-value for value in amesim_results.series(source_path)
)
return series_by_data_path
def _compute_inactive_mass_force_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for alias in sorted(mechanical_assembly.masses):
mass = mechanical_assembly.masses[alias].endstop()
x1 = amesim_results.series(f"x1@{alias}")
v1 = amesim_results.series(f"v1@{alias}")
series_by_data_path[f"Fmin@{alias}"] = tuple(
mass.lower_static_force_magnitude(displacement)
for displacement in x1
)
series_by_data_path[f"Fvisc@{alias}"] = tuple(
mass.viscous_friction_force(velocity)
for velocity in v1
)
series_by_data_path[f"Ffric@{alias}"] = tuple(0.0 for _ in x1)
return series_by_data_path
def _compute_zero_force_source_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
return {
f"fzero@{alias}": tuple(0.0 for _ in amesim_results.times)
for alias in sorted(mechanical_assembly.zero_force_sources)
}
def _compute_chamber_duplicate_series(
amesim_results: AmesimResults,
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for binding in observation_catalog.chambers.bindings:
pressure_series = amesim_results.series(binding.pressure_path)
temperature_series = amesim_results.series(binding.temperature_path)
for duplicate_path in binding.pressure_duplicate_paths:
series_by_data_path[duplicate_path] = tuple(pressure_series)
for duplicate_path in binding.temperature_duplicate_paths:
series_by_data_path[duplicate_path] = tuple(temperature_series)
return series_by_data_path
def _compute_orifice_reversed_series(
amesim_results: AmesimResults,
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for binding in observation_catalog.orifices.bindings:
series_by_data_path[binding.reversed_mass_flow_path] = tuple(
-value for value in amesim_results.series(binding.primary_mass_flow_path)
)
series_by_data_path[binding.reversed_enthalpy_flow_path] = tuple(
-value for value in amesim_results.series(binding.primary_enthalpy_flow_path)
)
return series_by_data_path
def _compute_line_reversed_series(
amesim_results: AmesimResults,
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for binding in observation_catalog.lines.by_submodel("PNL00R"):
if len(binding.mass_flow_paths) != 2 or len(binding.enthalpy_flow_paths) != 2:
raise ValueError(f"Expected two PNL00R flow paths for {binding.alias}.")
primary_mass_path, reversed_mass_path = binding.mass_flow_paths
primary_enthalpy_path, reversed_enthalpy_path = binding.enthalpy_flow_paths
series_by_data_path[reversed_mass_path] = tuple(
-value for value in amesim_results.series(primary_mass_path)
)
series_by_data_path[reversed_enthalpy_path] = tuple(
-value for value in amesim_results.series(primary_enthalpy_path)
)
return series_by_data_path
@@ -0,0 +1,151 @@
from __future__ import annotations
import ast
import operator
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
_BINARY_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
}
_UNARY_OPERATORS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}
class TestMqlExpressionError(ValueError):
"""Raised when an AMESim parameter expression cannot be resolved safely."""
@dataclass(frozen=True)
class TestMqlResolvedParameter:
name: str
title: str
raw_value: str
units: str
value: float | None
@property
def is_numeric(self) -> bool:
return self.value is not None
@dataclass(frozen=True)
class TestMqlResolvedComponent:
alias: str
component_name: str
submodel: str
label: str
parameters: dict[str, TestMqlResolvedParameter]
def parameter_value(self, name: str) -> float:
parameter = self.parameters[name]
if parameter.value is None:
raise KeyError(f"Parameter {name!r} on {self.alias!r} is not numeric")
return parameter.value
@dataclass(frozen=True)
class TestMqlConfig:
raw_global_parameters: dict[str, str]
global_parameters: dict[str, float]
fluid: PengRobinsonFluid
components: tuple[TestMqlResolvedComponent, ...]
@classmethod
def from_amesim_specs(cls) -> "TestMqlConfig":
raw_globals = dict(GLOBAL_PARAMETERS)
numeric_globals = {
name: value
for name, raw in raw_globals.items()
if (value := resolve_numeric_expression(raw, {})) is not None
}
components = tuple(
_resolve_component(spec, numeric_globals)
for spec in COMPONENT_SPECS
)
return cls(
raw_global_parameters=raw_globals,
global_parameters=numeric_globals,
fluid=HELIUM_PR,
components=components,
)
def component(self, alias: str) -> TestMqlResolvedComponent:
for component in self.components:
if component.alias == alias:
return component
raise KeyError(alias)
def components_by_submodel(self, submodel: str) -> tuple[TestMqlResolvedComponent, ...]:
return tuple(component for component in self.components if component.submodel == submodel)
def _resolve_component(
spec: dict[str, Any],
variables: dict[str, float],
) -> TestMqlResolvedComponent:
parameters = {}
for parameter in spec.get("parameters", []):
name = str(parameter["name"])
raw_value = str(parameter["value"])
parameters[name] = TestMqlResolvedParameter(
name=name,
title=str(parameter["title"]),
raw_value=raw_value,
units=str(parameter["units"]),
value=resolve_numeric_expression(raw_value, variables),
)
return TestMqlResolvedComponent(
alias=str(spec["alias"]),
component_name=str(spec["component_name"]),
submodel=str(spec["submodel"]),
label=str(spec["label"]),
parameters=parameters,
)
def resolve_numeric_expression(
expression: str,
variables: dict[str, float],
) -> float | None:
expression = expression.strip()
if not expression:
return None
normalized = expression.replace("^", "**")
try:
parsed = ast.parse(normalized, mode="eval")
value = float(_eval_node(parsed.body, variables))
except (SyntaxError, TestMqlExpressionError, ValueError, TypeError, ZeroDivisionError):
return None
return value if isfinite(value) else None
def _eval_node(node: ast.AST, variables: dict[str, float]) -> float:
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.Name):
if node.id not in variables:
raise TestMqlExpressionError(f"Unknown variable: {node.id}")
return float(variables[node.id])
if isinstance(node, ast.BinOp):
operator_type = type(node.op)
if operator_type not in _BINARY_OPERATORS:
raise TestMqlExpressionError(f"Unsupported binary operator: {operator_type}")
return float(_BINARY_OPERATORS[operator_type](_eval_node(node.left, variables), _eval_node(node.right, variables)))
if isinstance(node, ast.UnaryOp):
operator_type = type(node.op)
if operator_type not in _UNARY_OPERATORS:
raise TestMqlExpressionError(f"Unsupported unary operator: {operator_type}")
return float(_UNARY_OPERATORS[operator_type](_eval_node(node.operand, variables)))
raise TestMqlExpressionError(f"Unsupported expression node: {type(node)}")
@@ -0,0 +1,451 @@
from __future__ import annotations
import re
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
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
@dataclass(frozen=True)
class TestMqlPnl0001Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_temperature_k: float
initial_gauge_pressure_pa: float
@property
def initial_absolute_pressure_pa(self) -> float:
return self.initial_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl0002Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_center_temperature_k: float
initial_center_gauge_pressure_pa: float
@property
def initial_center_absolute_pressure_pa(self) -> float:
return self.initial_center_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl0003Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_temperature_1_k: float
initial_gauge_pressure_1_pa: float
initial_temperature_2_k: float
initial_gauge_pressure_2_pa: float
@property
def initial_absolute_pressure_1_pa(self) -> float:
return self.initial_gauge_pressure_1_pa + AMESIM_REFERENCE_PRESSURE_PA
@property
def initial_absolute_pressure_2_pa(self) -> float:
return self.initial_gauge_pressure_2_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl00rSpec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
gas_type_index: int
def load_test_mql_pnl0001_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0001Spec, ...]:
"""Load resolved PNL0001 geometry and initial states from the AMESim source."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0001"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0001":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0001 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _evar_values(block)
specs.append(
TestMqlPnl0001Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_temperature_k=_required_numeric(
alias, "t2", state_values, numeric_globals
),
initial_gauge_pressure_pa=_required_numeric(
alias, "p2", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0001 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl0002_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0002Spec, ...]:
"""Load resolved PNL0002 geometry and center compliance initial state."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0002"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0002":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0002 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _ivar_values(block)
specs.append(
TestMqlPnl0002Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_center_temperature_k=_required_numeric(
alias, "tctr", state_values, numeric_globals
),
initial_center_gauge_pressure_pa=_required_numeric(
alias, "pctr", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0002 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl0003_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0003Spec, ...]:
"""Load resolved PNL0003 geometry and both compliance initial states."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0003"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0003":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0003 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _evar_values(block)
specs.append(
TestMqlPnl0003Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_temperature_1_k=_required_numeric(
alias, "t1", state_values, numeric_globals
),
initial_gauge_pressure_1_pa=_required_numeric(
alias, "p1", state_values, numeric_globals
),
initial_temperature_2_k=_required_numeric(
alias, "t2", state_values, numeric_globals
),
initial_gauge_pressure_2_pa=_required_numeric(
alias, "p2", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0003 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl00r_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl00rSpec, ...]:
"""Load resolved PNL00R geometry from the AMESim source."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL00R"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL00R":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL00R line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
specs.append(
TestMqlPnl00rSpec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL00R parameter blocks: {missing}")
return tuple(specs)
def _parameter_expressions(block: str, tag_name: str) -> dict[str, str]:
parameters = {}
for parameter_block in re.findall(
rf"<{tag_name}>.*?</{tag_name}>",
block,
flags=re.DOTALL,
):
parameters[_required_text(parameter_block, "VARNAME")] = _required_text(
parameter_block,
"VALUE",
)
return parameters
def _ivar_values(block: str) -> dict[str, str]:
values = {}
for variable_block in re.findall(r"<IVAR>.*?</IVAR>", block, flags=re.DOTALL):
value = _optional_text(variable_block, "VALUE")
if value:
values[_required_text(variable_block, "VARNAME")] = value
return values
def _evar_values(block: str) -> dict[str, str]:
values = {}
for variable_block in re.findall(r"<EVAR>.*?</EVAR>", block, flags=re.DOTALL):
value = _optional_text(variable_block, "VALUE")
if value:
values[_required_text(variable_block, "VARNAME")] = value
return values
def _required_numeric(
alias: str,
name: str,
expressions: dict[str, str],
variables: dict[str, float],
) -> float:
if name not in expressions:
raise ValueError(f"Missing {name!r} on line {alias!r}")
value = resolve_numeric_expression(expressions[name], variables)
if value is None:
raise ValueError(
f"Cannot resolve {name!r}={expressions[name]!r} on line {alias!r}"
)
return value
def _required_text(block: str, tag_name: str) -> str:
value = _optional_text(block, tag_name)
if value is None:
raise ValueError(f"Missing AMESim circuit element: {tag_name}")
return value
def _optional_text(block: str, tag_name: str) -> str | None:
match = re.search(rf"<{tag_name}>(.*?)</{tag_name}>", block, flags=re.DOTALL)
return match.group(1).strip() if match is not None else None
@@ -0,0 +1,102 @@
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql import CONNECTION_SPECS
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
_LINE_PATTERN_RE = re.compile(r"\(([^()]+)\)\s*$")
@dataclass(frozen=True)
class TestMqlLineConnection:
index: int
alias: str
submodel: str
pattern: str
source_component: str
source_port: str
target_component: str
target_port: str
label: str
data_paths: tuple[str, ...]
signal_names: tuple[str, ...]
@property
def has_compliance(self) -> bool:
return "C" in self.pattern
@property
def has_resistance(self) -> bool:
return "R" in self.pattern
@dataclass(frozen=True)
class TestMqlLineAssembly:
lines: tuple[TestMqlLineConnection, ...]
@property
def line_count(self) -> int:
return len(self.lines)
def by_alias(self, alias: str) -> TestMqlLineConnection:
for line in self.lines:
if line.alias == alias:
return line
raise KeyError(alias)
def by_submodel(self, submodel: str) -> tuple[TestMqlLineConnection, ...]:
return tuple(line for line in self.lines if line.submodel == submodel)
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(line.submodel for line in self.lines))
def aliases(self) -> tuple[str, ...]:
return tuple(line.alias for line in self.lines)
def build_test_mql_line_assembly(
amesim_results: AmesimResults,
variable_catalog: TestMqlVariableCatalog | None = None,
) -> TestMqlLineAssembly:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(amesim_results)
lines = []
for spec in CONNECTION_SPECS:
submodel = str(spec["submodel"])
if submodel not in TEST_MQL_PNEUMATIC_LINE_SUBMODELS:
continue
data_paths = variable_catalog.data_paths_for_owner(str(spec["alias"]))
signal_names = tuple(path.rsplit("@", 1)[0] for path in data_paths)
lines.append(
TestMqlLineConnection(
index=int(spec["index"]),
alias=str(spec["alias"]),
submodel=submodel,
pattern=_line_pattern(str(spec["label"]), submodel),
source_component=str(spec["source_component"]),
source_port=str(spec["source_port"]),
target_component=str(spec["target_component"]),
target_port=str(spec["target_port"]),
label=str(spec["label"]),
data_paths=data_paths,
signal_names=signal_names,
)
)
return TestMqlLineAssembly(lines=tuple(lines))
def _line_pattern(label: str, submodel: str) -> str:
match = _LINE_PATTERN_RE.search(label)
if match is not None:
return match.group(1)
if submodel == "PNL00R":
return "R"
return submodel
@@ -0,0 +1,544 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_mechanical import (
AmesimElasticEndstop,
AmesimMassFrictionEndstops,
AmesimPistonGeometry,
circular_area,
mm_to_m,
)
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
MM_TO_M = 1.0e-3
N_PER_MM_TO_N_PER_M = 1.0e3
N_PER_MM_PER_S_TO_N_PER_M_PER_S = 1.0e3
@dataclass(frozen=True)
class TestMqlPistonSpec:
alias: str
piston_diameter_m: float
rod_diameter_m: float
zero_displacement_m: float
piston_area_m2: float
rod_area_m2: float
annulus_area_m2: float
data_paths: tuple[str, ...]
def geometry(self) -> AmesimPistonGeometry:
return AmesimPistonGeometry(
piston_diameter_m=self.piston_diameter_m,
rod_diameter_m=self.rod_diameter_m,
zero_length_m=self.zero_displacement_m,
)
@dataclass(frozen=True)
class TestMqlMassEndstopSpec:
alias: str
mass_kg: float
xmin_m: float
xmax_m: float
min_stiffness_n_per_m: float
max_stiffness_n_per_m: float
min_damping_n_per_m_per_s: float
max_damping_n_per_m_per_s: float
min_penetration_m: float
max_penetration_m: float
stiction_force_n: float
coulomb_friction_n: float
viscous_friction_n_per_m_per_s: float
windage_n_per_m2_per_s2: float
stick_velocity_threshold_m_s: float
reset_velocity_threshold_m_s: float
rest_coeff: float
stribeck_constant_m_s: float
use_friction: bool
stop_type: int
initial_velocity_m_s: float
initial_displacement_m: float
data_paths: tuple[str, ...]
def endstop(self) -> AmesimMassFrictionEndstops:
return AmesimMassFrictionEndstops(
mass_kg=self.mass_kg,
lower_limit_m=self.xmin_m,
upper_limit_m=self.xmax_m,
lower_stiffness_n_per_m=self.min_stiffness_n_per_m,
upper_stiffness_n_per_m=self.max_stiffness_n_per_m,
lower_damping_n_per_m_per_s=self.min_damping_n_per_m_per_s,
upper_damping_n_per_m_per_s=self.max_damping_n_per_m_per_s,
viscous_friction_n_per_m_per_s=self.viscous_friction_n_per_m_per_s,
coulomb_friction_n=self.coulomb_friction_n,
stiction_force_n=self.stiction_force_n,
windage_n_per_m2_per_s2=self.windage_n_per_m2_per_s2,
)
@dataclass(frozen=True)
class TestMqlElasticEndstopSpec:
alias: str
gap_m: float
contact_stiffness_n_per_m: float
contact_damping_n_per_m_per_s: float
spring_diameter_m: float
wire_diameter_m: float
data_paths: tuple[str, ...]
def endstop(self) -> AmesimElasticEndstop:
return AmesimElasticEndstop(
contact_stiffness_n_per_m=self.contact_stiffness_n_per_m,
contact_damping_n_per_m_per_s=self.contact_damping_n_per_m_per_s,
gap0_m=self.gap_m,
)
@dataclass(frozen=True)
class TestMqlMechanicalNodeSpec:
alias: str
port_count: int
sum_mode: int
data_paths: tuple[str, ...]
@dataclass(frozen=True)
class TestMqlPiecewiseLinearSignalSpec:
alias: str
t_start_s: float
starts: tuple[float, ...]
ends: tuple[float, ...]
durations_s: tuple[float, ...]
stage_count: int
is_cyclic: bool
data_paths: tuple[str, ...]
def output_at(self, time_s: float) -> float:
if self.stage_count <= 0:
return 0.0
elapsed = max(time_s - self.t_start_s, 0.0)
active_durations = self.durations_s[: self.stage_count]
total_duration = sum(active_durations)
if self.is_cyclic and total_duration > 0.0:
elapsed = elapsed % total_duration
stage_start_time = 0.0
for index, duration in enumerate(active_durations):
stage_end_time = stage_start_time + duration
if elapsed < stage_end_time or index == self.stage_count - 1:
if duration <= 0.0:
return self.ends[index]
fraction = (elapsed - stage_start_time) / duration
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
stage_start_time = stage_end_time
return self.ends[self.stage_count - 1]
@dataclass(frozen=True)
class TestMqlForceConnectorSpec:
alias: str
signal_alias: str
target_mass_alias: str
data_paths: tuple[str, ...]
def force_at(
self,
time_s: float,
signals: dict[str, TestMqlPiecewiseLinearSignalSpec],
) -> float:
return signals[self.signal_alias].output_at(time_s)
@dataclass(frozen=True)
class TestMqlMechanicalAssembly:
pistons: dict[str, TestMqlPistonSpec]
masses: dict[str, TestMqlMassEndstopSpec]
elastic_endstops: dict[str, TestMqlElasticEndstopSpec]
mechanical_nodes: dict[str, TestMqlMechanicalNodeSpec]
piecewise_signals: dict[str, TestMqlPiecewiseLinearSignalSpec]
force_connectors: dict[str, TestMqlForceConnectorSpec]
zero_force_sources: tuple[str, ...]
@property
def component_count(self) -> int:
return (
len(self.pistons)
+ len(self.masses)
+ len(self.elastic_endstops)
+ len(self.mechanical_nodes)
+ len(self.piecewise_signals)
+ len(self.force_connectors)
+ len(self.zero_force_sources)
)
@property
def aliases(self) -> tuple[str, ...]:
return tuple(
[
*self.pistons,
*self.masses,
*self.elastic_endstops,
*self.mechanical_nodes,
*self.piecewise_signals,
*self.force_connectors,
*self.zero_force_sources,
]
)
@dataclass(frozen=True)
class TestMqlMechanicalMassState:
alias: str
velocity_m_s: float
displacement_m: float
def as_vector(self) -> list[float]:
return [self.velocity_m_s, self.displacement_m]
@dataclass(frozen=True)
class TestMqlMechanicalNodeKinematics:
alias: str
velocities_m_s: dict[int, float]
displacements_m: dict[int, float]
@dataclass(frozen=True)
class TestMqlPistonKinematics:
alias: str
port_2_velocity_m_s: float
port_2_displacement_m: float
port_3_velocity_m_s: float
port_3_displacement_m: float
@dataclass(frozen=True)
class TestMqlMechanicalMassSnapshot:
states: tuple[TestMqlMechanicalMassState, ...]
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics]
piston_kinematics_by_alias: dict[str, TestMqlPistonKinematics]
@property
def state_count(self) -> int:
return 2 * len(self.states)
class TestMqlMechanicalMassClosure:
def __init__(self, assembly: TestMqlMechanicalAssembly) -> None:
self.assembly = assembly
self.mass_aliases = tuple(assembly.masses)
def initial_state_vector(self) -> list[float]:
state: list[float] = []
for alias in self.mass_aliases:
spec = self.assembly.masses[alias]
state.extend([spec.initial_velocity_m_s, spec.initial_displacement_m])
return state
def snapshot(self, state_vector: list[float] | None = None) -> TestMqlMechanicalMassSnapshot:
values = self.initial_state_vector() if state_vector is None else list(state_vector)
if len(values) != 2 * len(self.mass_aliases):
raise ValueError("mechanical mass state vector requires two values per mass")
states = tuple(
TestMqlMechanicalMassState(
alias=alias,
velocity_m_s=values[2 * index],
displacement_m=values[2 * index + 1],
)
for index, alias in enumerate(self.mass_aliases)
)
node_kinematics = self._node_kinematics_by_alias(states)
return TestMqlMechanicalMassSnapshot(
states=states,
node_kinematics_by_alias=node_kinematics,
piston_kinematics_by_alias=self._piston_kinematics_by_alias(
states,
node_kinematics,
),
)
def _node_kinematics_by_alias(
self,
states: tuple[TestMqlMechanicalMassState, ...],
) -> dict[str, TestMqlMechanicalNodeKinematics]:
state_by_alias = {state.alias: state for state in states}
front = state_by_alias["mass_friction_endstops_18"]
rear = state_by_alias["mass_friction_endstops_19"]
return {
"dynamic_mechanical_node_alternative_2": TestMqlMechanicalNodeKinematics(
alias="dynamic_mechanical_node_alternative_2",
velocities_m_s={port: -front.velocity_m_s for port in range(1, 9)},
displacements_m={port: -front.displacement_m for port in range(1, 9)},
),
"dynamic_mechanical_node_alternative_3": TestMqlMechanicalNodeKinematics(
alias="dynamic_mechanical_node_alternative_3",
velocities_m_s={port: rear.velocity_m_s for port in range(1, 9)},
displacements_m={port: rear.displacement_m for port in range(1, 9)},
),
}
def _piston_kinematics_by_alias(
self,
states: tuple[TestMqlMechanicalMassState, ...],
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics],
) -> dict[str, TestMqlPistonKinematics]:
state_by_alias = {state.alias: state for state in states}
rear_node = node_kinematics_by_alias["dynamic_mechanical_node_alternative_3"]
piston_bindings = (
("pn_brp2_8", "mass_friction_endstops_10", 8),
("pn_brp2_9", "mass_friction_endstops_11", 7),
("pn_brp2_10", "mass_friction_endstops_12", 6),
("pn_brp2_11", "mass_friction_endstops_13", 5),
("pn_brp2_12", "mass_friction_endstops_14", 4),
("pn_brp2_13", "mass_friction_endstops_15", 3),
("pn_brp2_14", "mass_friction_endstops_16", 2),
("pn_brp2_15", "mass_friction_endstops_17", 1),
)
return {
piston_alias: TestMqlPistonKinematics(
alias=piston_alias,
port_2_velocity_m_s=state_by_alias[mass_alias].velocity_m_s,
port_2_displacement_m=state_by_alias[mass_alias].displacement_m,
port_3_velocity_m_s=rear_node.velocities_m_s[rear_node_port],
port_3_displacement_m=rear_node.displacements_m[rear_node_port],
)
for piston_alias, mass_alias, rear_node_port in piston_bindings
}
def rhs(
self,
state_vector: list[float],
*,
force_by_mass_alias: dict[str, float] | None = None,
constrained_mass_aliases: set[str] | None = None,
) -> list[float]:
snapshot = self.snapshot(state_vector)
force_by_mass_alias = force_by_mass_alias or {}
constrained_mass_aliases = constrained_mass_aliases or set()
derivatives: list[float] = []
for state in snapshot.states:
spec = self.assembly.masses[state.alias]
mass = spec.endstop()
applied_force = force_by_mass_alias.get(state.alias, 0.0)
acceleration, velocity = mass.derivatives(
velocity_m_s=state.velocity_m_s,
displacement_m=state.displacement_m,
port_1_force_n=applied_force,
)
if state.alias in constrained_mass_aliases and _limit_constraint_holds(
spec,
state,
applied_force,
):
acceleration = 0.0
velocity = 0.0
derivatives.extend([acceleration, velocity])
return derivatives
def build_test_mql_mechanical_assembly(
config: TestMqlConfig | None = None,
amesim_results: AmesimResults | None = None,
variable_catalog: TestMqlVariableCatalog | None = None,
) -> TestMqlMechanicalAssembly:
config = config or TestMqlConfig.from_amesim_specs()
if variable_catalog is None and amesim_results is not None:
variable_catalog = build_test_mql_variable_catalog(amesim_results)
pistons = {
component.alias: _build_piston(component, variable_catalog)
for component in config.components_by_submodel("PNRP17")
}
masses = {
component.alias: _build_mass(component, variable_catalog, amesim_results)
for component in config.components_by_submodel("MECMAS21")
}
elastic_endstops = {
component.alias: _build_elastic_endstop(component, variable_catalog)
for component in config.components_by_submodel("LSTP00A")
}
mechanical_nodes = {
component.alias: _build_mechanical_node(component, variable_catalog)
for component in config.components_by_submodel("LMECHN1")
}
piecewise_signals = {
component.alias: _build_piecewise_signal(component, variable_catalog)
for component in config.components_by_submodel("UD00")
}
force_connectors = {
component.alias: _build_force_connector(component, variable_catalog)
for component in config.components_by_submodel("FORC")
}
zero_force_sources = tuple(component.alias for component in config.components_by_submodel("F000"))
return TestMqlMechanicalAssembly(
pistons=pistons,
masses=masses,
elastic_endstops=elastic_endstops,
mechanical_nodes=mechanical_nodes,
piecewise_signals=piecewise_signals,
force_connectors=force_connectors,
zero_force_sources=zero_force_sources,
)
def _build_piston(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlPistonSpec:
geometry = AmesimPistonGeometry(
piston_diameter_m=mm_to_m(component.parameter_value("dp")),
rod_diameter_m=mm_to_m(component.parameter_value("dr")),
zero_length_m=mm_to_m(component.parameter_value("x0")),
)
return TestMqlPistonSpec(
alias=component.alias,
piston_diameter_m=geometry.piston_diameter_m,
rod_diameter_m=geometry.rod_diameter_m,
zero_displacement_m=geometry.zero_length_m,
piston_area_m2=geometry.piston_area_m2,
rod_area_m2=geometry.rod_area_m2,
annulus_area_m2=geometry.annulus_area_m2,
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_mass(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
amesim_results: AmesimResults | None,
) -> TestMqlMassEndstopSpec:
return TestMqlMassEndstopSpec(
alias=component.alias,
mass_kg=component.parameter_value("mass"),
xmin_m=component.parameter_value("xmin"),
xmax_m=component.parameter_value("xmax"),
min_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmin")),
max_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmax")),
min_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmin")),
max_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmax")),
min_penetration_m=mm_to_m(component.parameter_value("Pdmin")),
max_penetration_m=mm_to_m(component.parameter_value("Pdmax")),
stiction_force_n=component.parameter_value("fstick"),
coulomb_friction_n=component.parameter_value("fcoul"),
viscous_friction_n_per_m_per_s=component.parameter_value("rvisc"),
windage_n_per_m2_per_s2=component.parameter_value("wind"),
stick_velocity_threshold_m_s=component.parameter_value("dvel"),
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
rest_coeff=component.parameter_value("restcoeff"),
stribeck_constant_m_s=component.parameter_value("astrib"),
use_friction=bool(int(component.parameter_value("useFriction"))),
stop_type=int(component.parameter_value("stoptype")),
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_elastic_endstop(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlElasticEndstopSpec:
return TestMqlElasticEndstopSpec(
alias=component.alias,
gap_m=mm_to_m(component.parameter_value("gap0")),
contact_stiffness_n_per_m=component.parameter_value("kcont"),
contact_damping_n_per_m_per_s=component.parameter_value("rcont"),
spring_diameter_m=mm_to_m(component.parameter_value("sdiam")),
wire_diameter_m=mm_to_m(component.parameter_value("wdiam")),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_mechanical_node(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlMechanicalNodeSpec:
return TestMqlMechanicalNodeSpec(
alias=component.alias,
port_count=int(component.parameter_value("v1")),
sum_mode=int(component.parameter_value("sum")),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _limit_constraint_holds(
spec: TestMqlMassEndstopSpec,
state: TestMqlMechanicalMassState,
applied_force_n: float,
) -> bool:
if abs(state.velocity_m_s) > spec.stick_velocity_threshold_m_s:
return False
at_lower_limit = state.displacement_m <= spec.xmin_m + spec.min_penetration_m
at_upper_limit = state.displacement_m >= spec.xmax_m - spec.max_penetration_m
return (at_lower_limit and applied_force_n <= 0.0) or (
at_upper_limit and applied_force_n >= 0.0
)
def _build_piecewise_signal(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlPiecewiseLinearSignalSpec:
starts = tuple(component.parameter_value(f"start{index}") for index in range(1, 9))
ends = tuple(component.parameter_value(f"end{index}") for index in range(1, 9))
durations = tuple(component.parameter_value(f"t{index}") for index in range(1, 9))
return TestMqlPiecewiseLinearSignalSpec(
alias=component.alias,
t_start_s=component.parameter_value("tstart"),
starts=starts,
ends=ends,
durations_s=durations,
stage_count=int(component.parameter_value("nstages")),
is_cyclic=bool(int(component.parameter_value("iscyclic"))),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_force_connector(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlForceConnectorSpec:
signal_alias_by_force_connector = {
"forcecon_1": "piecewiselinear",
"forcecon_2": "piecewiselinear_1",
}
target_mass_by_force_connector = {
"forcecon_1": "mass_friction_endstops_19",
"forcecon_2": "mass_friction_endstops_18",
}
return TestMqlForceConnectorSpec(
alias=component.alias,
signal_alias=signal_alias_by_force_connector[component.alias],
target_mass_alias=target_mass_by_force_connector[component.alias],
data_paths=_data_paths(variable_catalog, component.alias),
)
def n_per_mm_to_n_per_m(value: float) -> float:
return value * N_PER_MM_TO_N_PER_M
def n_per_mm_per_s_to_n_per_m_per_s(value: float) -> float:
return value * N_PER_MM_PER_S_TO_N_PER_M_PER_S
def _initial_value(amesim_results: AmesimResults | None, data_path: str) -> float:
if amesim_results is None:
return 0.0
return float(amesim_results.series(data_path)[0])
def _data_paths(
variable_catalog: TestMqlVariableCatalog | None,
alias: str,
) -> tuple[str, ...]:
if variable_catalog is None:
return ()
return variable_catalog.data_paths_for_owner(alias)
@@ -0,0 +1,215 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.systems.test_mql import COMPONENT_SPECS
@dataclass(frozen=True)
class TestMqlPneumaticNode3Balance:
temperature_k: float
pressure_pa: float
port_1_enthalpy_flow_w: float
port_1_mass_flow_g_s: float
port_1_volume_derivative_l_min: float
port_1_volume_cm3: float
port_2_enthalpy_flow_w: float
port_2_mass_flow_g_s: float
port_2_volume_derivative_l_min: float
port_2_volume_cm3: float
port_3_enthalpy_flow_w: float
port_3_mass_flow_g_s: float
port_3_volume_derivative_l_min: float
port_3_volume_cm3: float
@dataclass(frozen=True)
class TestMqlPneumaticNode3:
"""Exact algebraic contract of AMESim ``PN3NODE2``.
Pressure and temperature are fixed by port 2 and duplicated to ports 1 and
3. Flow and volume signals at port 2 are the sums of ports 1 and 3, matching
the ``EXPRESS2`` equations stored in ``test_mql_.cir``.
"""
alias: str
def balance(
self,
*,
port_2_temperature_k: float,
port_2_pressure_pa: float,
port_1_enthalpy_flow_w: float,
port_1_mass_flow_g_s: float,
port_3_enthalpy_flow_w: float,
port_3_mass_flow_g_s: float,
port_1_volume_derivative_l_min: float = 0.0,
port_1_volume_cm3: float = 0.0,
port_3_volume_derivative_l_min: float = 0.0,
port_3_volume_cm3: float = 0.0,
) -> TestMqlPneumaticNode3Balance:
if port_2_temperature_k <= 0.0:
raise ValueError("port_2_temperature_k must be positive")
if port_2_pressure_pa <= 0.0:
raise ValueError("port_2_pressure_pa must be positive")
return TestMqlPneumaticNode3Balance(
temperature_k=port_2_temperature_k,
pressure_pa=port_2_pressure_pa,
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
port_1_volume_cm3=port_1_volume_cm3,
port_2_enthalpy_flow_w=(
port_1_enthalpy_flow_w + port_3_enthalpy_flow_w
),
port_2_mass_flow_g_s=port_1_mass_flow_g_s + port_3_mass_flow_g_s,
port_2_volume_derivative_l_min=(
port_1_volume_derivative_l_min + port_3_volume_derivative_l_min
),
port_2_volume_cm3=port_1_volume_cm3 + port_3_volume_cm3,
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
port_3_volume_cm3=port_3_volume_cm3,
)
@dataclass(frozen=True)
class TestMqlPneumaticNode4Balance:
temperature_k: float
pressure_pa: float
port_1_enthalpy_flow_w: float
port_1_mass_flow_g_s: float
port_1_volume_derivative_l_min: float
port_1_volume_cm3: float
port_2_enthalpy_flow_w: float
port_2_mass_flow_g_s: float
port_2_volume_derivative_l_min: float
port_2_volume_cm3: float
port_3_enthalpy_flow_w: float
port_3_mass_flow_g_s: float
port_3_volume_derivative_l_min: float
port_3_volume_cm3: float
port_4_enthalpy_flow_w: float
port_4_mass_flow_g_s: float
port_4_volume_derivative_l_min: float
port_4_volume_cm3: float
@dataclass(frozen=True)
class TestMqlPneumaticNode4:
"""Exact algebraic contract of AMESim ``P4NODE2``.
Pressure and temperature are fixed by port 2 and duplicated to ports 1, 3,
and 4. Flow and volume signals at port 2 are the sums of ports 1, 3, and
4, matching the saved AMESim variables for ``pnnode4_*`` instances.
"""
alias: str
def balance(
self,
*,
port_2_temperature_k: float,
port_2_pressure_pa: float,
port_1_enthalpy_flow_w: float,
port_1_mass_flow_g_s: float,
port_3_enthalpy_flow_w: float,
port_3_mass_flow_g_s: float,
port_4_enthalpy_flow_w: float,
port_4_mass_flow_g_s: float,
port_1_volume_derivative_l_min: float = 0.0,
port_1_volume_cm3: float = 0.0,
port_3_volume_derivative_l_min: float = 0.0,
port_3_volume_cm3: float = 0.0,
port_4_volume_derivative_l_min: float = 0.0,
port_4_volume_cm3: float = 0.0,
) -> TestMqlPneumaticNode4Balance:
if port_2_temperature_k <= 0.0:
raise ValueError("port_2_temperature_k must be positive")
if port_2_pressure_pa <= 0.0:
raise ValueError("port_2_pressure_pa must be positive")
return TestMqlPneumaticNode4Balance(
temperature_k=port_2_temperature_k,
pressure_pa=port_2_pressure_pa,
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
port_1_volume_cm3=port_1_volume_cm3,
port_2_enthalpy_flow_w=(
port_1_enthalpy_flow_w
+ port_3_enthalpy_flow_w
+ port_4_enthalpy_flow_w
),
port_2_mass_flow_g_s=(
port_1_mass_flow_g_s
+ port_3_mass_flow_g_s
+ port_4_mass_flow_g_s
),
port_2_volume_derivative_l_min=(
port_1_volume_derivative_l_min
+ port_3_volume_derivative_l_min
+ port_4_volume_derivative_l_min
),
port_2_volume_cm3=(
port_1_volume_cm3 + port_3_volume_cm3 + port_4_volume_cm3
),
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
port_3_volume_cm3=port_3_volume_cm3,
port_4_enthalpy_flow_w=port_4_enthalpy_flow_w,
port_4_mass_flow_g_s=port_4_mass_flow_g_s,
port_4_volume_derivative_l_min=port_4_volume_derivative_l_min,
port_4_volume_cm3=port_4_volume_cm3,
)
@dataclass(frozen=True)
class TestMqlP4NodePortConnection:
line_alias: str
local_node_alias: str
local_port: str
remote_node_alias: str
remote_port: str
@dataclass(frozen=True)
class TestMqlP4NodePrimaryConnection:
line_alias: str
node_alias: str
node_port: str
chamber_alias: str
chamber_port: str
@dataclass(frozen=True)
class TestMqlP4NodeOrificeConnection:
orifice_alias: str
node_alias: str
node_port: str
direct_line_alias: str
@dataclass(frozen=True)
class TestMqlP4NodeNeighborhood:
node_alias: str
primary: TestMqlP4NodePrimaryConnection
port_1: TestMqlP4NodePortConnection
port_3: TestMqlP4NodePortConnection
port_4: TestMqlP4NodeOrificeConnection
def build_test_mql_node3_assembly() -> dict[str, TestMqlPneumaticNode3]:
return {
str(spec["alias"]): TestMqlPneumaticNode3(alias=str(spec["alias"]))
for spec in COMPONENT_SPECS
if spec["submodel"] == "PN3NODE2"
}
def build_test_mql_node4_assembly() -> dict[str, TestMqlPneumaticNode4]:
return {
str(spec["alias"]): TestMqlPneumaticNode4(alias=str(spec["alias"]))
for spec in COMPONENT_SPECS
if spec["submodel"] == "P4NODE2"
}
@@ -0,0 +1,273 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
AmesimVariablePneumaticVolume,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
BAR_TO_PA = 1.0e5
DEFAULT_TEST_MQL_TEMPERATURE_K = 293.15
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR = 1.0
# Matched to PNVO001 event-window mass flow near the 0.04 s opening event.
TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER = 0.99805
@dataclass(frozen=True)
class TestMqlStepSignalSpec:
alias: str
initial_output: float
final_output: float
step_time_s: float
transition_duration_s: float
transition_type: int
def output_at(self, time_s: float) -> float:
if self.transition_type != 1:
raise ValueError(
f"unsupported STEP0 transition type {self.transition_type} on {self.alias}"
)
return self.initial_output if time_s < self.step_time_s else self.final_output
@dataclass(frozen=True)
class TestMqlVariableOrificeControl:
orifice_alias: str
step: TestMqlStepSignalSpec
def opening_at(self, time_s: float) -> float:
return self.step.output_at(time_s)
@dataclass(frozen=True)
class TestMqlPneumaticAssembly:
fixed_chambers: dict[str, AmesimPneumaticVolume]
variable_chambers: dict[str, AmesimVariablePneumaticVolume]
fixed_orifices: dict[str, AmesimPneumaticOrifice]
variable_orifices: dict[str, AmesimPneumaticOrifice]
variable_orifice_controls: dict[str, TestMqlVariableOrificeControl]
fixed_initial_absolute_pressure_pa: float
variable_initial_absolute_pressure_pa: float
@property
def initial_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
@property
def fixed_initial_gauge_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
@property
def variable_initial_gauge_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.variable_initial_absolute_pressure_pa)
@property
def chamber_count(self) -> int:
return len(self.fixed_chambers) + len(self.variable_chambers)
@property
def orifice_count(self) -> int:
return len(self.fixed_orifices) + len(self.variable_orifices)
@property
def component_count(self) -> int:
return self.chamber_count + self.orifice_count
@property
def variable_orifice_control_count(self) -> int:
return len(self.variable_orifice_controls)
def set_variable_orifice_openings(self, time_s: float) -> None:
for alias, control in self.variable_orifice_controls.items():
self.variable_orifices[alias].opening = control.opening_at(time_s)
@property
def aliases(self) -> tuple[str, ...]:
return tuple(
[
*self.fixed_chambers,
*self.variable_chambers,
*self.fixed_orifices,
*self.variable_orifices,
]
)
def build_test_mql_pneumatic_assembly(
config: TestMqlConfig | None = None,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPneumaticAssembly:
config = config or TestMqlConfig.from_amesim_specs()
fixed_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
config.global_parameters["P0"]
)
variable_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR
)
fixed_chambers = {
component.alias: _build_chamber(
component,
volume_parameter="cvol",
gas=gas,
initial_pressure_pa=fixed_initial_absolute_pressure_pa,
)
for component in config.components_by_submodel("PNCH023")
}
variable_chambers = {
component.alias: _build_chamber(
component,
volume_parameter="cvol0",
gas=gas,
initial_pressure_pa=variable_initial_absolute_pressure_pa,
)
for component in config.components_by_submodel("PNCH012")
}
fixed_orifices = {
component.alias: _build_orifice(
component,
area_parameter="area",
gas=gas,
opening=1.0,
)
for component in config.components_by_submodel("PNOR001")
}
variable_orifice_controls = _build_variable_orifice_controls(config)
variable_orifices = {
component.alias: _build_orifice(
component,
area_parameter="area0",
gas=gas,
opening=variable_orifice_controls[component.alias].opening_at(0.0),
)
for component in config.components_by_submodel("PNVO001")
}
return TestMqlPneumaticAssembly(
fixed_chambers=fixed_chambers,
variable_chambers=variable_chambers,
fixed_orifices=fixed_orifices,
variable_orifices=variable_orifices,
variable_orifice_controls=variable_orifice_controls,
fixed_initial_absolute_pressure_pa=fixed_initial_absolute_pressure_pa,
variable_initial_absolute_pressure_pa=variable_initial_absolute_pressure_pa,
)
def _build_variable_orifice_controls(
config: TestMqlConfig,
) -> dict[str, TestMqlVariableOrificeControl]:
from PythonModels.systems.test_mql import CONNECTION_SPECS
components_by_alias = {component.alias: component for component in config.components}
variable_orifice_aliases = {
component.alias for component in config.components_by_submodel("PNVO001")
}
controls: dict[str, TestMqlVariableOrificeControl] = {}
for connection in CONNECTION_SPECS:
if connection["submodel"] != "DIRECT":
continue
source_alias = str(connection["source_component"])
target_alias = str(connection["target_component"])
if target_alias in variable_orifice_aliases:
orifice_alias = target_alias
step_alias = source_alias
elif source_alias in variable_orifice_aliases:
orifice_alias = source_alias
step_alias = target_alias
else:
continue
step_component = components_by_alias.get(step_alias)
if step_component is None or step_component.submodel != "STEP0":
continue
controls[orifice_alias] = TestMqlVariableOrificeControl(
orifice_alias=orifice_alias,
step=TestMqlStepSignalSpec(
alias=step_alias,
initial_output=step_component.parameter_value("out0"),
final_output=step_component.parameter_value("out1"),
step_time_s=step_component.parameter_value("t0"),
transition_duration_s=step_component.parameter_value("td"),
transition_type=int(step_component.parameter_value("transitionType")),
),
)
missing = variable_orifice_aliases - controls.keys()
if missing:
raise ValueError(
"missing STEP0 controls for PNVO001 components: "
+ ", ".join(sorted(missing))
)
return controls
def absolute_pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
return pressure_bar * BAR_TO_PA
def pressure_to_amesim_gauge_pa(absolute_pressure_pa: float) -> float:
return absolute_pressure_pa - AMESIM_REFERENCE_PRESSURE_PA
def pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
return pressure_to_amesim_gauge_pa(absolute_pressure_from_amesim_bar_parameter(pressure_bar))
def _build_chamber(
component: TestMqlResolvedComponent,
*,
volume_parameter: str,
gas: AmesimPneumaticGas,
initial_pressure_pa: float,
) -> AmesimPneumaticVolume:
if volume_parameter == "cvol0":
return AmesimVariablePneumaticVolume.from_liters(
name=component.alias,
dead_volume_liters=component.parameter_value(volume_parameter),
gas=gas,
p0=initial_pressure_pa,
T0=_component_temperature(component),
heat_transfer_coefficient=component.parameter_value("kth"),
heat_transfer_area=component.parameter_value("sth"),
external_temperature_k=_component_temperature(component),
)
return AmesimPneumaticVolume.from_liters(
name=component.alias,
volume_liters=component.parameter_value(volume_parameter),
gas=gas,
p0=initial_pressure_pa,
T0=_component_temperature(component),
heat_transfer_coefficient=component.parameter_value("kth"),
heat_transfer_area=component.parameter_value("sth"),
external_temperature_k=_component_temperature(component),
)
def _build_orifice(
component: TestMqlResolvedComponent,
*,
area_parameter: str,
gas: AmesimPneumaticGas,
opening: float,
) -> AmesimPneumaticOrifice:
flow_coefficient = component.parameter_value("cq")
if component.submodel == "PNVO001":
flow_coefficient *= TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER
return AmesimPneumaticOrifice.from_mm2(
name=component.alias,
area_mm2=component.parameter_value(area_parameter),
flow_coefficient=flow_coefficient,
gas=gas,
opening=opening,
)
def _component_temperature(component: TestMqlResolvedComponent) -> float:
parameter = component.parameters.get("extemp")
if parameter is None or parameter.value is None:
return DEFAULT_TEST_MQL_TEMPERATURE_K
return parameter.value
@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
)
from PythonModels.components.amesim_pneumatic_line import (
AmesimPnl0001Pipe,
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
from PythonModels.systems.test_mql_line_parameters import (
TestMqlPnl0001Spec,
TestMqlPnl0002Spec,
TestMqlPnl0003Spec,
TestMqlPnl00rSpec,
load_test_mql_pnl0001_specs,
load_test_mql_pnl0002_specs,
load_test_mql_pnl0003_specs,
load_test_mql_pnl00r_specs,
)
TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE = 5.5636e-6
@dataclass(frozen=True)
class TestMqlPnl0001Assembly:
specs: tuple[TestMqlPnl0001Spec, ...]
lines: dict[str, AmesimPnl0001Pipe]
def spec(self, alias: str) -> TestMqlPnl0001Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl0002Assembly:
specs: tuple[TestMqlPnl0002Spec, ...]
lines: dict[str, AmesimPnl0002Pipe]
def spec(self, alias: str) -> TestMqlPnl0002Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl0003Assembly:
specs: tuple[TestMqlPnl0003Spec, ...]
lines: dict[str, AmesimPnl0003Pipe]
def spec(self, alias: str) -> TestMqlPnl0003Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl00rAssembly:
specs: tuple[TestMqlPnl00rSpec, ...]
lines: dict[str, AmesimPnl00rPipe]
def spec(self, alias: str) -> TestMqlPnl00rSpec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
def build_test_mql_pnl0001_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0001Assembly:
specs = load_test_mql_pnl0001_specs(archive_path)
lines = {
spec.alias: AmesimPnl0001Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
calibrated_linear_conductance=(
_test_mql_pnl0001_calibrated_linear_conductance(spec)
),
gas=gas,
p0=spec.initial_absolute_pressure_pa,
T0=spec.initial_temperature_k,
)
for spec in specs
}
return TestMqlPnl0001Assembly(specs=specs, lines=lines)
def _test_mql_pnl0001_calibrated_linear_conductance(
spec: TestMqlPnl0001Spec,
) -> float | None:
if spec.target_component.startswith("pn_c1_") and _matches_geometry(
spec, diameter_mm=20.0, length_m=1.0
):
return TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE
return None
def _matches_geometry(
spec: TestMqlPnl0001Spec,
*,
diameter_mm: float,
length_m: float,
) -> bool:
return (
abs(spec.diameter_mm - diameter_mm) < 1.0e-12
and abs(spec.length_m - length_m) < 1.0e-12
)
def build_test_mql_pnl0002_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0002Assembly:
specs = load_test_mql_pnl0002_specs(archive_path)
lines = {
spec.alias: AmesimPnl0002Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
gas=gas,
pctr_0=spec.initial_center_absolute_pressure_pa,
Tctr_0=spec.initial_center_temperature_k,
)
for spec in specs
}
return TestMqlPnl0002Assembly(specs=specs, lines=lines)
def build_test_mql_pnl0003_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0003Assembly:
specs = load_test_mql_pnl0003_specs(archive_path)
lines = {
spec.alias: AmesimPnl0003Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
gas=gas,
p1_0=spec.initial_absolute_pressure_1_pa,
T1_0=spec.initial_temperature_1_k,
p2_0=spec.initial_absolute_pressure_2_pa,
T2_0=spec.initial_temperature_2_k,
)
for spec in specs
}
return TestMqlPnl0003Assembly(specs=specs, lines=lines)
def build_test_mql_pnl00r_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl00rAssembly:
specs = load_test_mql_pnl00r_specs(archive_path)
lines = {
spec.alias: AmesimPnl00rPipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
gas=gas,
)
for spec in specs
}
return TestMqlPnl00rAssembly(specs=specs, lines=lines)
@@ -0,0 +1,128 @@
from __future__ import annotations
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
def discover_fixed_chamber_segments(
topology: TestMqlCirTopology,
component_specs: list[dict[str, object]],
connection_specs: list[dict[str, object]],
) -> tuple[TestMqlPneumaticChamberSegmentSpec, ...]:
submodel_by_alias = {
str(component["alias"]): str(component["submodel"])
for component in component_specs
}
segments = []
for component in component_specs:
volume_alias = str(component["alias"])
if component["submodel"] != "PNCH023":
continue
orifice_contacts = []
for contact in topology.contacts_for(volume_alias):
other_alias, other_port = contact.other_endpoint(volume_alias)
if submodel_by_alias.get(other_alias) == "PNOR001":
orifice_contacts.append(
(
other_alias,
other_port,
contact.port_for(volume_alias),
)
)
if len(orifice_contacts) != 2:
raise ValueError(
f"{volume_alias} must contact exactly two PNOR001 orifices; "
f"found {len(orifice_contacts)}"
)
sides = [
_resolve_orifice_boundary(
orifice_alias=orifice_alias,
orifice_volume_port=orifice_volume_port,
volume_port=volume_port,
connection_specs=connection_specs,
submodel_by_alias=submodel_by_alias,
)
for orifice_alias, orifice_volume_port, volume_port in orifice_contacts
]
inlet_sides = [side for side in sides if side["role"] == "inlet"]
outlet_sides = [side for side in sides if side["role"] == "outlet"]
if len(inlet_sides) != 1 or len(outlet_sides) != 1:
raise ValueError(
f"{volume_alias} requires one inlet and one outlet topology side"
)
inlet = inlet_sides[0]
outlet = outlet_sides[0]
segments.append(
TestMqlPneumaticChamberSegmentSpec(
name=f"{volume_alias}_segment",
inlet_node_alias=inlet["node_alias"],
inlet_line_alias=inlet["line_alias"],
inlet_orifice_alias=inlet["orifice_alias"],
inlet_orifice_boundary_port=inlet["orifice_boundary_port"],
inlet_orifice_volume_port=inlet["orifice_volume_port"],
volume_alias=volume_alias,
volume_inlet_port=inlet["volume_port"],
volume_outlet_port=outlet["volume_port"],
outlet_orifice_alias=outlet["orifice_alias"],
outlet_orifice_volume_port=outlet["orifice_volume_port"],
outlet_orifice_boundary_port=outlet["orifice_boundary_port"],
outlet_line_alias=outlet["line_alias"],
outlet_node_alias=outlet["node_alias"],
)
)
return tuple(segments)
def _resolve_orifice_boundary(
*,
orifice_alias: str,
orifice_volume_port: str,
volume_port: str,
connection_specs: list[dict[str, object]],
submodel_by_alias: dict[str, str],
) -> dict[str, str]:
boundary_connections = []
for connection in connection_specs:
if (
connection["source_component"] == orifice_alias
and connection["source_port"] != orifice_volume_port
) or (
connection["target_component"] == orifice_alias
and connection["target_port"] != orifice_volume_port
):
boundary_connections.append(connection)
if len(boundary_connections) != 1:
raise ValueError(
f"{orifice_alias} must have exactly one non-volume boundary connection; "
f"found {len(boundary_connections)}"
)
connection = boundary_connections[0]
if connection["submodel"] != "PNL0001":
raise ValueError(
f"{orifice_alias} boundary must use PNL0001, got {connection['submodel']}"
)
if connection["target_component"] == orifice_alias:
role = "inlet"
node_alias = str(connection["source_component"])
orifice_boundary_port = str(connection["target_port"])
else:
role = "outlet"
node_alias = str(connection["target_component"])
orifice_boundary_port = str(connection["source_port"])
if submodel_by_alias.get(node_alias) != "PN3NODE2":
raise ValueError(
f"{orifice_alias} PNL0001 boundary must terminate at PN3NODE2, "
f"got {node_alias}"
)
return {
"role": role,
"node_alias": node_alias,
"line_alias": str(connection["alias"]),
"orifice_alias": orifice_alias,
"orifice_boundary_port": orifice_boundary_port,
"orifice_volume_port": orifice_volume_port,
"volume_port": volume_port,
}
@@ -0,0 +1,118 @@
from __future__ import annotations
import re
import tarfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class TestMqlComponentContact:
component_a: str
port_a: str
component_b: str
port_b: str
def other_endpoint(self, component_alias: str) -> tuple[str, str]:
if component_alias == self.component_a:
return self.component_b, self.port_b
if component_alias == self.component_b:
return self.component_a, self.port_a
raise KeyError(component_alias)
def port_for(self, component_alias: str) -> str:
if component_alias == self.component_a:
return self.port_a
if component_alias == self.component_b:
return self.port_b
raise KeyError(component_alias)
@dataclass(frozen=True)
class TestMqlCirTopology:
component_contacts: tuple[TestMqlComponentContact, ...]
def contacts_for(self, component_alias: str) -> tuple[TestMqlComponentContact, ...]:
return tuple(
contact
for contact in self.component_contacts
if component_alias in (contact.component_a, contact.component_b)
)
def load_test_mql_cir_topology(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> TestMqlCirTopology:
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
root = ET.fromstring(_topology_only_xml(cir_text))
components = root.findall(".//COMPS_LIST/COMP")
aliases = tuple(_required_text(component, "ALIAS") for component in components)
contacts: dict[
tuple[tuple[int, int], tuple[int, int]],
TestMqlComponentContact,
] = {}
directed_contacts: set[tuple[tuple[int, int], tuple[int, int]]] = set()
for component_index, component in enumerate(components):
ports = component.findall("./COMP_PORTS_LIST/COMP_PORT")
for port_index, port in enumerate(ports):
if port.findtext("PORT_CONNECT") != "1":
continue
for connection in port.findall("./CONNECT_LIST/CONNECT"):
target_index = int(_required_text(connection, "CONNECT_ENTITY_NUM"))
target_port_index = int(_required_text(connection, "CONNECT_ENTITY_PORT"))
if target_index < 0 or target_index >= len(components):
raise ValueError(f"Component contact references unknown entity {target_index}")
target_ports = components[target_index].findall("./COMP_PORTS_LIST/COMP_PORT")
if target_port_index < 0 or target_port_index >= len(target_ports):
raise ValueError(
f"Component contact references unknown port {target_port_index} "
f"on {aliases[target_index]}"
)
endpoint = (component_index, port_index)
target_endpoint = (target_index, target_port_index)
directed_contacts.add((endpoint, target_endpoint))
key = tuple(sorted((endpoint, target_endpoint)))
first, second = key
contacts[key] = TestMqlComponentContact(
component_a=aliases[first[0]],
port_a=f"port_{first[1] + 1}",
component_b=aliases[second[0]],
port_b=f"port_{second[1] + 1}",
)
for endpoint, target_endpoint in directed_contacts:
if (target_endpoint, endpoint) not in directed_contacts:
raise ValueError(
"AMESim component contact is not reciprocal: "
f"{endpoint} -> {target_endpoint}"
)
return TestMqlCirTopology(component_contacts=tuple(contacts.values()))
def _topology_only_xml(cir_text: str) -> str:
# AMESim expressions inside SUBMODEL contain unescaped && and <= operators.
# Topology lives outside those blocks, so omit them before XML parsing.
return re.sub(
r"<SUBMODEL>.*?</SUBMODEL>",
"<SUBMODEL />",
cir_text,
flags=re.DOTALL,
)
def _required_text(element: ET.Element, child_name: str) -> str:
value = element.findtext(child_name)
if value is None:
raise ValueError(f"Missing AMESim circuit element: {child_name}")
return value
+1
View File
@@ -27,6 +27,7 @@ ParameterSpec = ParameterDefinition
ENABLED_COMPONENT_LIBRARIES = (
"app.simulation.components.experimental.library:LIBRARY",
"app.simulation.components.amesim.library:LIBRARY",
)
_MACHINE_ID_PATTERN = re.compile(r"[a-z][a-z0-9_]*")
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import re
import struct
import tarfile
from dataclasses import dataclass
from pathlib import Path
class AmesimResultsError(ValueError):
"""Raised when AMESim result files cannot be parsed consistently."""
@dataclass(frozen=True)
class AmesimVariable:
index: int
label: str
data_path: str | None
param_id: int | None
hidden: bool
@dataclass(frozen=True)
class AmesimResults:
times: tuple[float, ...]
variables: tuple[AmesimVariable, ...]
saved_variable_indices: tuple[int, ...]
series_by_data_path: dict[str, tuple[float, ...]]
final_values_by_data_path: dict[str, float]
@property
def point_count(self) -> int:
return len(self.times)
@property
def saved_variable_count(self) -> int:
return len(self.saved_variable_indices)
def series(self, data_path: str) -> tuple[float, ...]:
return self.series_by_data_path[data_path]
def final_value(self, data_path: str) -> float:
return self.final_values_by_data_path[data_path]
_DATA_PATH_RE = re.compile(r"Data_Path=(\S+)")
_PARAM_ID_RE = re.compile(r"Param_Id=(\d+)")
def load_test_mql_amesim_results(
archive_path: str | Path,
*,
time_stop_s: float | None = None,
) -> AmesimResults:
return load_amesim_results_from_archive(
archive_path=archive_path,
var_member=None,
results_member=None,
time_stop_s=time_stop_s,
)
def load_amesim_results_from_archive(
*,
archive_path: str | Path,
var_member: str | None,
results_member: str | None,
time_stop_s: float | None = None,
) -> AmesimResults:
with tarfile.open(archive_path) as archive:
var_member, results_member = _resolve_result_members(
archive,
var_member=var_member,
results_member=results_member,
)
var_file = archive.extractfile(var_member)
results_file = archive.extractfile(results_member)
if var_file is None:
raise AmesimResultsError(f"Missing AMESim variable member: {var_member}")
if results_file is None:
raise AmesimResultsError(f"Missing AMESim results member: {results_member}")
var_lines = var_file.read().decode("latin1").splitlines()
variables = tuple(
_parse_variable_line(index, line) for index, line in enumerate(var_lines)
)
if time_stop_s is not None:
return _parse_amesim_results_window(
results_file,
variables,
time_stop_s=time_stop_s,
)
results_data = results_file.read()
return parse_amesim_results_bytes(results_data, variables)
def _resolve_result_members(
archive: tarfile.TarFile,
*,
var_member: str | None,
results_member: str | None,
) -> tuple[str, str]:
member_names = set(archive.getnames())
if var_member is not None or results_member is not None:
if var_member is None or results_member is None:
raise AmesimResultsError(
"var_member and results_member must either both be set or both be omitted."
)
return var_member, results_member
preferred = ("test_mql_.var", "test_mql_.results")
if preferred[0] in member_names and preferred[1] in member_names:
return preferred
pairs = sorted(
(name, f"{name[:-4]}.results")
for name in member_names
if name.endswith(".var") and f"{name[:-4]}.results" in member_names
)
if len(pairs) != 1:
raise AmesimResultsError(
"Unable to identify a unique AMESim .var/.results member pair."
)
return pairs[0]
def _parse_amesim_results_window(
results_file,
variables: tuple[AmesimVariable, ...],
*,
time_stop_s: float,
) -> AmesimResults:
header = results_file.read(8)
if len(header) < 8:
raise AmesimResultsError("AMESim results data is too small.")
point_count, encoded_saved_variable_count = struct.unpack("<2i", header)
saved_variable_count = abs(encoded_saved_variable_count)
if point_count <= 0 or saved_variable_count <= 0:
raise AmesimResultsError("Invalid AMESim results header.")
mapping_data = results_file.read(saved_variable_count * 4)
if len(mapping_data) != saved_variable_count * 4:
raise AmesimResultsError("AMESim results variable mapping is truncated.")
saved_variable_indices = struct.unpack(
f"<{saved_variable_count}i",
mapping_data,
)
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
raise AmesimResultsError(
"AMESim results variable mapping references unknown .var rows."
)
row_length = 1 + saved_variable_count
row_byte_count = row_length * 8
times: list[float] = []
series_lists: dict[str, list[float]] = {}
saved_paths: list[tuple[int, str]] = []
for column, variable_index in enumerate(saved_variable_indices, start=1):
data_path = variables[variable_index].data_path
if data_path is None:
continue
series_lists[data_path] = []
saved_paths.append((column, data_path))
for _row_index in range(point_count):
row = results_file.read(row_byte_count)
if len(row) != row_byte_count:
raise AmesimResultsError("AMESim results matrix is truncated.")
time_s = struct.unpack_from("<d", row, 0)[0]
times.append(time_s)
for column, data_path in saved_paths:
series_lists[data_path].append(
struct.unpack_from("<d", row, column * 8)[0]
)
# Keep one real sample after the requested stop so endpoint finite
# differences do not silently fall back to a backward-only slope.
if time_s > time_stop_s + 1.0e-12:
break
return AmesimResults(
times=tuple(times),
variables=variables,
saved_variable_indices=tuple(saved_variable_indices),
series_by_data_path={
data_path: tuple(values) for data_path, values in series_lists.items()
},
final_values_by_data_path={},
)
def parse_amesim_results_bytes(
results_data: bytes,
variables: tuple[AmesimVariable, ...],
) -> AmesimResults:
if len(results_data) < 8:
raise AmesimResultsError("AMESim results data is too small.")
point_count, encoded_saved_variable_count = struct.unpack_from("<2i", results_data, 0)
saved_variable_count = abs(encoded_saved_variable_count)
if point_count <= 0 or saved_variable_count <= 0:
raise AmesimResultsError("Invalid AMESim results header.")
mapping_offset = 8
mapping_size = saved_variable_count * 4
data_offset = mapping_offset + mapping_size
saved_variable_indices = struct.unpack_from(
f"<{saved_variable_count}i",
results_data,
mapping_offset,
)
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
raise AmesimResultsError(
"AMESim results variable mapping references unknown .var rows."
)
row_length = 1 + saved_variable_count
main_value_count = point_count * row_length
main_byte_count = main_value_count * 8
main_end = data_offset + main_byte_count
if main_end > len(results_data):
raise AmesimResultsError("AMESim results matrix is truncated.")
main_values = struct.unpack_from(f"<{main_value_count}d", results_data, data_offset)
times = tuple(main_values[row * row_length] for row in range(point_count))
series_by_data_path: dict[str, tuple[float, ...]] = {}
for column, variable_index in enumerate(saved_variable_indices, start=1):
variable = variables[variable_index]
if variable.data_path is None:
continue
series_by_data_path[variable.data_path] = tuple(
main_values[row * row_length + column]
for row in range(point_count)
)
final_values_by_data_path: dict[str, float] = {}
trailing_bytes = len(results_data) - main_end
expected_final_bytes = (1 + len(variables)) * 8
if trailing_bytes >= expected_final_bytes:
final_values = struct.unpack_from(f"<{1 + len(variables)}d", results_data, main_end)
for variable, value in zip(variables, final_values[1:]):
if variable.data_path is not None:
final_values_by_data_path[variable.data_path] = value
return AmesimResults(
times=times,
variables=variables,
saved_variable_indices=tuple(saved_variable_indices),
series_by_data_path=series_by_data_path,
final_values_by_data_path=final_values_by_data_path,
)
def _parse_variable_line(index: int, line: str) -> AmesimVariable:
data_path_match = _DATA_PATH_RE.search(line)
param_id_match = _PARAM_ID_RE.search(line)
label = line
if data_path_match is not None:
label = line[: data_path_match.start()].strip()
return AmesimVariable(
index=index,
label=label,
data_path=data_path_match.group(1) if data_path_match else None,
param_id=int(param_id_match.group(1)) if param_id_match else None,
hidden="HIDDEN" in line,
)
@@ -0,0 +1,195 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@dataclass(frozen=True)
class TestMqlChamberObservation:
time: float
pressure_pa: float
temperature_k: float
gas_mass_g: float
volume_cm3: float | None
@dataclass(frozen=True)
class TestMqlChamberBinding:
alias: str
submodel: str
pressure_path: str
temperature_path: str
gas_mass_path: str
pressure_duplicate_paths: tuple[str, ...]
temperature_duplicate_paths: tuple[str, ...]
volume_path: str | None
@property
def is_variable(self) -> bool:
return self.volume_path is not None
def observation_at(self, results: AmesimResults, index: int) -> TestMqlChamberObservation:
return TestMqlChamberObservation(
time=results.times[index],
pressure_pa=results.series(self.pressure_path)[index],
temperature_k=results.series(self.temperature_path)[index],
gas_mass_g=results.series(self.gas_mass_path)[index],
volume_cm3=(
results.series(self.volume_path)[index]
if self.volume_path is not None
else None
),
)
@dataclass(frozen=True)
class TestMqlChamberObservationCatalog:
bindings: tuple[TestMqlChamberBinding, ...]
@property
def fixed_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNCH023")
@property
def variable_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNCH012")
def by_alias(self, alias: str) -> TestMqlChamberBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def build_test_mql_chamber_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
assembly: TestMqlPneumaticAssembly | None = None,
) -> TestMqlChamberObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
assembly = assembly or build_test_mql_pneumatic_assembly()
chamber_aliases = {
**{alias: "PNCH023" for alias in assembly.fixed_chambers},
**{alias: "PNCH012" for alias in assembly.variable_chambers},
}
bindings = []
for alias, submodel in chamber_aliases.items():
variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
pressure = _primary_observable(variables, "press", expected_units="Pa")
temperature = _primary_observable(variables, "temp", expected_units="K")
gas_mass = _required_path(
variables,
"mgas1" if submodel == "PNCH012" else "mgas",
expected_units="g",
)
volume = _optional_path(variables, "vol", expected_units="cm**3")
bindings.append(
TestMqlChamberBinding(
alias=alias,
submodel=submodel,
pressure_path=pressure.data_path,
temperature_path=temperature.data_path,
gas_mass_path=gas_mass,
pressure_duplicate_paths=_duplicate_paths(variables, "press", expected_units="Pa"),
temperature_duplicate_paths=_duplicate_paths(variables, "temp", expected_units="K"),
volume_path=volume,
)
)
return TestMqlChamberObservationCatalog(
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
)
def _primary_observable(
variables: tuple[TestMqlVariableBinding, ...],
signal_prefix: str,
*,
expected_units: str,
) -> TestMqlVariableBinding:
matches = tuple(
variable
for variable in variables
if variable.signal_name == signal_prefix
and "duplicate" not in variable.label
)
variable = _single(matches, f"primary {signal_prefix}")
_assert_units(variable, expected_units)
return variable
def _duplicate_paths(
variables: tuple[TestMqlVariableBinding, ...],
signal_prefix: str,
*,
expected_units: str,
) -> tuple[str, ...]:
matches = tuple(
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and variable.signal_name != signal_prefix
and "duplicate" in variable.label
)
for variable in matches:
_assert_units(variable, expected_units)
return tuple(variable.data_path for variable in matches)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str,
) -> str:
variable = _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
_assert_units(variable, expected_units)
return variable.data_path
def _optional_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str,
) -> str | None:
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
if not matches:
return None
variable = _single(matches, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -0,0 +1,237 @@
from __future__ import annotations
from bisect import bisect_left
import csv
from dataclasses import dataclass
from pathlib import Path
from PythonModels.reporting.amesim_results import AmesimResults
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
"temp3@pn_c1_8",
"press3@pn_c1_8",
"vvol1@pn_brp2_8",
"vol1@pn_brp2_8",
)
@dataclass(frozen=True)
class TestMqlComparisonMetric:
data_path: str
sample_count: int
max_abs_error: float
mean_abs_error: float
max_rel_error: float
final_abs_error: float
@dataclass(frozen=True)
class TestMqlComparisonResult:
metrics: tuple[TestMqlComparisonMetric, ...]
def metric(self, data_path: str) -> TestMqlComparisonMetric:
for metric in self.metrics:
if metric.data_path == data_path:
return metric
raise KeyError(data_path)
@property
def max_abs_error(self) -> float:
return max((metric.max_abs_error for metric in self.metrics), default=0.0)
@property
def max_rel_error(self) -> float:
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
class TestMqlComparisonError(ValueError):
"""Raised when Python and AMESim series cannot be aligned."""
def compare_test_mql_series(
*,
python_times: tuple[float, ...] | list[float],
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
relative_floor: float = 1.0e-12,
) -> TestMqlComparisonResult:
_validate_time_axis(python_times)
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
metrics = []
for data_path in selected_paths:
python_values = tuple(float(value) for value in python_series_by_data_path[data_path])
if len(python_values) != len(python_times):
raise TestMqlComparisonError(
f"Python series length mismatch for {data_path!r}: "
f"{len(python_values)} values for {len(python_times)} time samples."
)
amesim_values = amesim_results.series(data_path)
abs_errors = []
rel_errors = []
for time_value, python_value in zip(python_times, python_values):
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
abs_error = abs(python_value - amesim_value)
abs_errors.append(abs_error)
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
final_amesim_value = interpolate_series_value(
amesim_results.times,
amesim_values,
float(python_times[-1]),
)
metrics.append(
TestMqlComparisonMetric(
data_path=data_path,
sample_count=len(python_times),
max_abs_error=max(abs_errors, default=0.0),
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
max_rel_error=max(rel_errors, default=0.0),
final_abs_error=abs(python_values[-1] - final_amesim_value),
)
)
return TestMqlComparisonResult(metrics=tuple(metrics))
def write_test_mql_amesim_baseline_csv(
output_dir: Path,
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] = DEFAULT_TEST_MQL_ALIGNMENT_PATHS,
) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
csv_path = output_dir / "test_mql_amesim_baseline.csv"
_validate_amesim_data_paths(amesim_results, data_paths)
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["time_s", *data_paths])
for index, time_value in enumerate(amesim_results.times):
writer.writerow(
[time_value, *(amesim_results.series(data_path)[index] for data_path in data_paths)]
)
return csv_path
def write_test_mql_comparison_csv(
*,
output_dir: Path,
python_times: tuple[float, ...] | list[float],
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> tuple[Path, Path, TestMqlComparisonResult]:
output_dir.mkdir(parents=True, exist_ok=True)
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
comparison = compare_test_mql_series(
python_times=python_times,
python_series_by_data_path=python_series_by_data_path,
amesim_results=amesim_results,
data_paths=selected_paths,
)
csv_path = output_dir / "test_mql_amesim_comparison.csv"
summary_path = output_dir / "test_mql_amesim_comparison_summary.txt"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
header = ["time_s"]
for data_path in selected_paths:
header.extend(
[
f"python.{data_path}",
f"amesim.{data_path}",
f"abs_error.{data_path}",
f"rel_error.{data_path}",
]
)
writer.writerow(header)
for index, time_value in enumerate(python_times):
row = [time_value]
for data_path in selected_paths:
python_value = float(python_series_by_data_path[data_path][index])
amesim_value = interpolate_series_value(
amesim_results.times,
amesim_results.series(data_path),
float(time_value),
)
abs_error = abs(python_value - amesim_value)
rel_error = abs_error / max(abs(amesim_value), 1.0e-12)
row.extend([python_value, amesim_value, abs_error, rel_error])
writer.writerow(row)
summary_lines = [
(
f"{metric.data_path}: samples={metric.sample_count}, "
f"max_abs_error={metric.max_abs_error:.12g}, "
f"mean_abs_error={metric.mean_abs_error:.12g}, "
f"max_rel_error={metric.max_rel_error:.12%}, "
f"final_abs_error={metric.final_abs_error:.12g}"
)
for metric in comparison.metrics
]
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
return csv_path, summary_path, comparison
def interpolate_series_value(
time_values: tuple[float, ...] | list[float],
values: tuple[float, ...] | list[float],
target_time: float,
) -> float:
if len(time_values) != len(values):
raise TestMqlComparisonError("time and value series lengths differ.")
if not time_values:
raise TestMqlComparisonError("cannot interpolate an empty series.")
if target_time <= time_values[0]:
return float(values[0])
if target_time >= time_values[-1]:
return float(values[-1])
right_index = bisect_left(time_values, target_time)
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1.0e-12:
return float(values[right_index])
left_index = right_index - 1
left_time = float(time_values[left_index])
right_time = float(time_values[right_index])
fraction = (target_time - left_time) / (right_time - left_time)
return float(values[left_index]) + fraction * (float(values[right_index]) - float(values[left_index]))
def _select_data_paths(
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None,
) -> tuple[str, ...]:
if data_paths is None:
data_paths = tuple(
data_path
for data_path in python_series_by_data_path
if data_path in amesim_results.series_by_data_path
)
selected_paths = tuple(data_paths)
if not selected_paths:
raise TestMqlComparisonError("no common Data_Path values are available for comparison.")
missing_python = [data_path for data_path in selected_paths if data_path not in python_series_by_data_path]
if missing_python:
raise TestMqlComparisonError(f"Python series missing Data_Path values: {missing_python}")
_validate_amesim_data_paths(amesim_results, selected_paths)
return selected_paths
def _validate_amesim_data_paths(
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str],
) -> None:
missing_amesim = [data_path for data_path in data_paths if data_path not in amesim_results.series_by_data_path]
if missing_amesim:
raise TestMqlComparisonError(f"AMESim results missing Data_Path values: {missing_amesim}")
def _validate_time_axis(time_values: tuple[float, ...] | list[float]) -> None:
if not time_values:
raise TestMqlComparisonError("Python time axis is empty.")
previous = float(time_values[0])
for value in time_values[1:]:
value = float(value)
if value < previous:
raise TestMqlComparisonError("Python time axis must be monotonically increasing.")
previous = value
@@ -0,0 +1,211 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_lines import (
TestMqlLineAssembly,
build_test_mql_line_assembly,
)
G_PER_S_TO_KG_PER_S = 1.0e-3
@dataclass(frozen=True)
class TestMqlLineObservation:
time: float
mass_flows_kg_s: dict[str, float]
enthalpy_flows_w: dict[str, float]
pressures_pa: dict[str, float]
temperatures_k: dict[str, float]
gas_mass_g: float | None
reynolds_number: float
mass_flow_parameter: float
gas_velocity_m_s: float
friction_factor: float
@dataclass(frozen=True)
class TestMqlLineObservationBinding:
alias: str
submodel: str
pattern: str
mass_flow_paths: tuple[str, ...]
enthalpy_flow_paths: tuple[str, ...]
pressure_paths: tuple[str, ...]
temperature_paths: tuple[str, ...]
gas_mass_path: str | None
reynolds_path: str
mass_flow_parameter_path: str
gas_velocity_path: str
friction_factor_path: str
def mass_flow_kg_s_series(
self,
results: AmesimResults,
data_path: str | None = None,
) -> tuple[float, ...]:
path = data_path or self.mass_flow_paths[0]
if path not in self.mass_flow_paths:
raise KeyError(path)
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(path))
def observation_at(self, results: AmesimResults, index: int) -> TestMqlLineObservation:
return TestMqlLineObservation(
time=results.times[index],
mass_flows_kg_s={
path: results.series(path)[index] * G_PER_S_TO_KG_PER_S
for path in self.mass_flow_paths
},
enthalpy_flows_w={
path: results.series(path)[index]
for path in self.enthalpy_flow_paths
},
pressures_pa={
path: results.series(path)[index]
for path in self.pressure_paths
},
temperatures_k={
path: results.series(path)[index]
for path in self.temperature_paths
},
gas_mass_g=(
results.series(self.gas_mass_path)[index]
if self.gas_mass_path is not None
else None
),
reynolds_number=results.series(self.reynolds_path)[index],
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
friction_factor=results.series(self.friction_factor_path)[index],
)
@dataclass(frozen=True)
class TestMqlLineObservationCatalog:
bindings: tuple[TestMqlLineObservationBinding, ...]
@property
def line_count(self) -> int:
return len(self.bindings)
def by_alias(self, alias: str) -> TestMqlLineObservationBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def by_submodel(self, submodel: str) -> tuple[TestMqlLineObservationBinding, ...]:
return tuple(binding for binding in self.bindings if binding.submodel == submodel)
def build_test_mql_line_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
line_assembly: TestMqlLineAssembly | None = None,
) -> TestMqlLineObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
line_assembly = line_assembly or build_test_mql_line_assembly(results, variable_catalog)
bindings = []
for line in line_assembly.lines:
variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == line.alias
)
bindings.append(
TestMqlLineObservationBinding(
alias=line.alias,
submodel=line.submodel,
pattern=line.pattern,
mass_flow_paths=_paths_with_prefix(variables, "dm", expected_units="g/s"),
enthalpy_flow_paths=_paths_with_prefix(variables, "dh", expected_units="J/s"),
pressure_paths=_paths_with_prefix(variables, "p", expected_units="Pa"),
temperature_paths=_paths_with_prefix(variables, "t", expected_units="K"),
gas_mass_path=_optional_path(variables, "mgas", expected_units="g"),
reynolds_path=_required_path(variables, "re", expected_units=None),
mass_flow_parameter_path=_required_path(
variables,
"cm",
expected_units="(kg*K/J)**(1/2)",
),
gas_velocity_path=_required_path(variables, "v", expected_units="m/s"),
friction_factor_path=_required_path(variables, "ff", expected_units=None),
)
)
return TestMqlLineObservationCatalog(bindings=tuple(bindings))
def _paths_with_prefix(
variables: tuple[TestMqlVariableBinding, ...],
prefix: str,
*,
expected_units: str | None,
) -> tuple[str, ...]:
matches = tuple(
variable
for variable in variables
if variable.signal_name.startswith(prefix)
)
for variable in matches:
_assert_units(variable, expected_units)
return tuple(variable.data_path for variable in matches)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str:
variable = _single_signal(variables, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _optional_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str | None:
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
if not matches:
return None
variable = _single(matches, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _single_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding:
return _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -0,0 +1,395 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_mechanical import (
TestMqlMechanicalAssembly,
build_test_mql_mechanical_assembly,
)
@dataclass(frozen=True)
class TestMqlPistonObservation:
time: float
chamber_volume_cm3: float
chamber_volume_rate_l_min: float
chamber_length_mm: float
force_port_2_n: float
force_port_3_n: float
displacement_port_2_m: float
velocity_port_2_m_s: float
displacement_port_3_m: float
velocity_port_3_m_s: float
@dataclass(frozen=True)
class TestMqlMassEndstopObservation:
time: float
displacement_m: float
velocity_m_s: float
acceleration_m_s2: float
lower_contact_force_n: float
upper_contact_force_n: float
viscous_friction_force_n: float
dry_friction_force_n: float
stick_flag: float
@dataclass(frozen=True)
class TestMqlElasticEndstopObservation:
time: float
force_n: float
duplicate_force_n: float
gap_mm: float
stiffness_n_m: float
@dataclass(frozen=True)
class TestMqlForceSourceObservation:
time: float
force_n: float
@dataclass(frozen=True)
class TestMqlForceConnectorObservation:
time: float
force_n: float
@dataclass(frozen=True)
class TestMqlMechanicalNodeObservation:
time: float
velocities_m_s: dict[int, float]
displacements_m: dict[int, float]
total_force_n: float
@dataclass(frozen=True)
class TestMqlPistonObservationBinding:
alias: str
volume_path: str
volume_rate_path: str
length_path: str
force_port_2_path: str
force_port_3_path: str
displacement_port_2_path: str
velocity_port_2_path: str
displacement_port_3_path: str
velocity_port_3_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlPistonObservation:
return TestMqlPistonObservation(
time=results.times[index],
chamber_volume_cm3=results.series(self.volume_path)[index],
chamber_volume_rate_l_min=results.series(self.volume_rate_path)[index],
chamber_length_mm=results.series(self.length_path)[index],
force_port_2_n=results.series(self.force_port_2_path)[index],
force_port_3_n=results.series(self.force_port_3_path)[index],
displacement_port_2_m=results.series(self.displacement_port_2_path)[index],
velocity_port_2_m_s=results.series(self.velocity_port_2_path)[index],
displacement_port_3_m=results.series(self.displacement_port_3_path)[index],
velocity_port_3_m_s=results.series(self.velocity_port_3_path)[index],
)
@dataclass(frozen=True)
class TestMqlMassEndstopObservationBinding:
alias: str
displacement_path: str
velocity_path: str
acceleration_path: str
displacement_duplicate_path: str
velocity_duplicate_path: str
acceleration_duplicate_path: str
lower_contact_force_path: str
upper_contact_force_path: str
viscous_friction_force_path: str
dry_friction_force_path: str
stick_flag_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMassEndstopObservation:
return TestMqlMassEndstopObservation(
time=results.times[index],
displacement_m=results.series(self.displacement_path)[index],
velocity_m_s=results.series(self.velocity_path)[index],
acceleration_m_s2=results.series(self.acceleration_path)[index],
lower_contact_force_n=results.series(self.lower_contact_force_path)[index],
upper_contact_force_n=results.series(self.upper_contact_force_path)[index],
viscous_friction_force_n=results.series(self.viscous_friction_force_path)[index],
dry_friction_force_n=results.series(self.dry_friction_force_path)[index],
stick_flag=results.series(self.stick_flag_path)[index],
)
@dataclass(frozen=True)
class TestMqlElasticEndstopObservationBinding:
alias: str
force_path: str
duplicate_force_path: str
gap_path: str
stiffness_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlElasticEndstopObservation:
return TestMqlElasticEndstopObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
duplicate_force_n=results.series(self.duplicate_force_path)[index],
gap_mm=results.series(self.gap_path)[index],
stiffness_n_m=results.series(self.stiffness_path)[index],
)
@dataclass(frozen=True)
class TestMqlForceSourceObservationBinding:
alias: str
force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceSourceObservation:
return TestMqlForceSourceObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
)
@dataclass(frozen=True)
class TestMqlForceConnectorObservationBinding:
alias: str
force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceConnectorObservation:
return TestMqlForceConnectorObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
)
@dataclass(frozen=True)
class TestMqlMechanicalNodeObservationBinding:
alias: str
velocity_paths_by_port: dict[int, str]
displacement_paths_by_port: dict[int, str]
total_force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMechanicalNodeObservation:
return TestMqlMechanicalNodeObservation(
time=results.times[index],
velocities_m_s={
port: results.series(path)[index]
for port, path in self.velocity_paths_by_port.items()
},
displacements_m={
port: results.series(path)[index]
for port, path in self.displacement_paths_by_port.items()
},
total_force_n=results.series(self.total_force_path)[index],
)
@dataclass(frozen=True)
class TestMqlMechanicalObservationCatalog:
pistons: dict[str, TestMqlPistonObservationBinding]
masses: dict[str, TestMqlMassEndstopObservationBinding]
elastic_endstops: dict[str, TestMqlElasticEndstopObservationBinding]
zero_force_sources: dict[str, TestMqlForceSourceObservationBinding]
force_connectors: dict[str, TestMqlForceConnectorObservationBinding]
mechanical_nodes: dict[str, TestMqlMechanicalNodeObservationBinding]
@property
def binding_count(self) -> int:
return (
len(self.pistons)
+ len(self.masses)
+ len(self.elastic_endstops)
+ len(self.zero_force_sources)
+ len(self.force_connectors)
+ len(self.mechanical_nodes)
)
def build_test_mql_mechanical_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
mechanical_assembly: TestMqlMechanicalAssembly | None = None,
) -> TestMqlMechanicalObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
mechanical_assembly = mechanical_assembly or build_test_mql_mechanical_assembly(
amesim_results=results,
variable_catalog=variable_catalog,
)
return TestMqlMechanicalObservationCatalog(
pistons={
alias: _build_piston_binding(alias, variable_catalog)
for alias in mechanical_assembly.pistons
},
masses={
alias: _build_mass_binding(alias, variable_catalog)
for alias in mechanical_assembly.masses
},
elastic_endstops={
alias: _build_elastic_endstop_binding(alias, variable_catalog)
for alias in mechanical_assembly.elastic_endstops
},
zero_force_sources={
alias: _build_zero_force_source_binding(alias, variable_catalog)
for alias in mechanical_assembly.zero_force_sources
},
force_connectors={
alias: _build_force_connector_binding(alias, variable_catalog)
for alias in mechanical_assembly.force_connectors
},
mechanical_nodes={
alias: _build_mechanical_node_binding(alias, variable_catalog)
for alias in mechanical_assembly.mechanical_nodes
},
)
def _build_piston_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlPistonObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlPistonObservationBinding(
alias=alias,
volume_path=_required_path(variables, "vol1", expected_units="cm**3"),
volume_rate_path=_required_path(variables, "vvol1", expected_units="L/min"),
length_path=_required_path(variables, "length", expected_units="mm"),
force_port_2_path=_required_path(variables, "f2", expected_units="N"),
force_port_3_path=_required_path(variables, "f3", expected_units="N"),
displacement_port_2_path=_required_path(variables, "x5", expected_units="m"),
velocity_port_2_path=_required_path(variables, "v5", expected_units="m/s"),
displacement_port_3_path=_required_path(variables, "x4", expected_units="m"),
velocity_port_3_path=_required_path(variables, "v4", expected_units="m/s"),
)
def _build_mass_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlMassEndstopObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlMassEndstopObservationBinding(
alias=alias,
displacement_path=_required_path(variables, "x1", expected_units="m"),
velocity_path=_required_path(variables, "v1", expected_units="m/s"),
acceleration_path=_required_path(variables, "acc1", expected_units="m/s/s"),
displacement_duplicate_path=_required_path(variables, "x1dup", expected_units="m"),
velocity_duplicate_path=_required_path(variables, "v1dup", expected_units="m/s"),
acceleration_duplicate_path=_required_path(variables, "acc1dup", expected_units="m/s/s"),
lower_contact_force_path=_required_path(variables, "Fmin", expected_units="N"),
upper_contact_force_path=_required_path(variables, "Fmax", expected_units="N"),
viscous_friction_force_path=_required_path(variables, "Fvisc", expected_units="N"),
dry_friction_force_path=_required_path(variables, "Ffric", expected_units="N"),
stick_flag_path=_required_path(variables, "stick", expected_units=None),
)
def _build_elastic_endstop_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlElasticEndstopObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlElasticEndstopObservationBinding(
alias=alias,
force_path=_required_path(variables, "f1", expected_units="N"),
duplicate_force_path=_required_path(variables, "f2", expected_units="N"),
gap_path=_required_path(variables, "gap", expected_units="mm"),
stiffness_path=_required_path(variables, "kval", expected_units="N/m"),
)
def _build_zero_force_source_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlForceSourceObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlForceSourceObservationBinding(
alias=alias,
force_path=_required_path(variables, "fzero", expected_units="N"),
)
def _build_force_connector_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlForceConnectorObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlForceConnectorObservationBinding(
alias=alias,
force_path=_required_path(variables, "force", expected_units="N"),
)
def _build_mechanical_node_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlMechanicalNodeObservationBinding:
variables = _owner_variables(variable_catalog, alias)
velocity_paths_by_port = {}
displacement_paths_by_port = {}
for port in range(1, 9):
velocity_paths_by_port[port] = _required_path(
variables,
f"p{port}__vt",
expected_units="m/s",
)
displacement_paths_by_port[port] = _required_path(
variables,
f"p{port}__xt",
expected_units="m",
)
return TestMqlMechanicalNodeObservationBinding(
alias=alias,
velocity_paths_by_port=velocity_paths_by_port,
displacement_paths_by_port=displacement_paths_by_port,
total_force_path=_required_path(variables, "tforce", expected_units="N"),
)
def _owner_variables(
variable_catalog: TestMqlVariableCatalog,
alias: str,
) -> tuple[TestMqlVariableBinding, ...]:
return tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str:
variable = _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
_assert_units(variable, expected_units)
return variable.data_path
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -0,0 +1,210 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_chamber_observations import (
TestMqlChamberObservationCatalog,
build_test_mql_chamber_observation_catalog,
)
from PythonModels.reporting.test_mql_line_observations import (
TestMqlLineObservationCatalog,
build_test_mql_line_observation_catalog,
)
from PythonModels.reporting.test_mql_mechanical_observations import (
TestMqlMechanicalObservationCatalog,
build_test_mql_mechanical_observation_catalog,
)
from PythonModels.reporting.test_mql_orifice_observations import (
TestMqlOrificeObservationCatalog,
build_test_mql_orifice_observation_catalog,
)
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
@dataclass(frozen=True)
class TestMqlObservationCatalog:
variable_catalog: TestMqlVariableCatalog
chambers: TestMqlChamberObservationCatalog
orifices: TestMqlOrificeObservationCatalog
lines: TestMqlLineObservationCatalog
mechanical: TestMqlMechanicalObservationCatalog
@property
def binding_count(self) -> int:
return (
len(self.chambers.bindings)
+ len(self.orifices.bindings)
+ self.lines.line_count
+ self.mechanical.binding_count
)
def data_paths_by_domain(self) -> dict[str, tuple[str, ...]]:
return {
"chambers": _sorted_unique(_chamber_data_paths(self.chambers)),
"orifices": _sorted_unique(_orifice_data_paths(self.orifices)),
"lines": _sorted_unique(_line_data_paths(self.lines)),
"mechanical": _sorted_unique(_mechanical_data_paths(self.mechanical)),
}
def data_paths(self) -> tuple[str, ...]:
paths = []
for domain_paths in self.data_paths_by_domain().values():
paths.extend(domain_paths)
return _sorted_unique(paths)
def baseline_series_by_data_path(
self,
results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> dict[str, tuple[float, ...]]:
selected_paths = tuple(data_paths) if data_paths is not None else self.data_paths()
_validate_observed_paths(self, selected_paths)
return {data_path: results.series(data_path) for data_path in selected_paths}
def build_test_mql_observation_catalog(results: AmesimResults) -> TestMqlObservationCatalog:
variable_catalog = build_test_mql_variable_catalog(results)
return TestMqlObservationCatalog(
variable_catalog=variable_catalog,
chambers=build_test_mql_chamber_observation_catalog(
results,
variable_catalog=variable_catalog,
),
orifices=build_test_mql_orifice_observation_catalog(
results,
variable_catalog=variable_catalog,
),
lines=build_test_mql_line_observation_catalog(
results,
variable_catalog=variable_catalog,
),
mechanical=build_test_mql_mechanical_observation_catalog(
results,
variable_catalog=variable_catalog,
),
)
def _chamber_data_paths(catalog: TestMqlChamberObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(
[
binding.pressure_path,
binding.temperature_path,
binding.gas_mass_path,
*binding.pressure_duplicate_paths,
*binding.temperature_duplicate_paths,
]
)
if binding.volume_path is not None:
paths.append(binding.volume_path)
return tuple(paths)
def _orifice_data_paths(catalog: TestMqlOrificeObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(
[
binding.primary_mass_flow_path,
binding.primary_enthalpy_flow_path,
binding.reversed_mass_flow_path,
binding.reversed_enthalpy_flow_path,
binding.mass_flow_parameter_path,
binding.gas_velocity_path,
]
)
if binding.opening_path is not None:
paths.append(binding.opening_path)
return tuple(paths)
def _line_data_paths(catalog: TestMqlLineObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(binding.mass_flow_paths)
paths.extend(binding.enthalpy_flow_paths)
paths.extend(binding.pressure_paths)
paths.extend(binding.temperature_paths)
if binding.gas_mass_path is not None:
paths.append(binding.gas_mass_path)
paths.extend(
[
binding.reynolds_path,
binding.mass_flow_parameter_path,
binding.gas_velocity_path,
binding.friction_factor_path,
]
)
return tuple(paths)
def _mechanical_data_paths(catalog: TestMqlMechanicalObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.pistons.values():
paths.extend(
[
binding.volume_path,
binding.volume_rate_path,
binding.length_path,
binding.force_port_2_path,
binding.force_port_3_path,
binding.displacement_port_2_path,
binding.velocity_port_2_path,
binding.displacement_port_3_path,
binding.velocity_port_3_path,
]
)
for binding in catalog.masses.values():
paths.extend(
[
binding.displacement_path,
binding.velocity_path,
binding.acceleration_path,
binding.displacement_duplicate_path,
binding.velocity_duplicate_path,
binding.acceleration_duplicate_path,
binding.lower_contact_force_path,
binding.upper_contact_force_path,
binding.viscous_friction_force_path,
binding.dry_friction_force_path,
binding.stick_flag_path,
]
)
for binding in catalog.elastic_endstops.values():
paths.extend(
[
binding.force_path,
binding.duplicate_force_path,
binding.gap_path,
binding.stiffness_path,
]
)
for binding in catalog.zero_force_sources.values():
paths.append(binding.force_path)
for binding in catalog.force_connectors.values():
paths.append(binding.force_path)
for binding in catalog.mechanical_nodes.values():
paths.extend(binding.velocity_paths_by_port.values())
paths.extend(binding.displacement_paths_by_port.values())
paths.append(binding.total_force_path)
return tuple(paths)
def _validate_observed_paths(
catalog: TestMqlObservationCatalog,
data_paths: tuple[str, ...],
) -> None:
observed_paths = set(catalog.data_paths())
missing = [data_path for data_path in data_paths if data_path not in observed_paths]
if missing:
raise KeyError(f"Data_Path values are not in the test_mql observation catalog: {missing}")
def _sorted_unique(data_paths: tuple[str, ...] | list[str]) -> tuple[str, ...]:
return tuple(sorted(set(data_paths)))
@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
G_PER_S_TO_KG_PER_S = 1.0e-3
@dataclass(frozen=True)
class TestMqlOrificeObservation:
time: float
mass_flow_kg_s: float
enthalpy_flow_w: float
mass_flow_parameter: float
gas_velocity_m_s: float
opening: float
effective_area_m2: float
@dataclass(frozen=True)
class TestMqlOrificeBinding:
alias: str
submodel: str
nominal_area_m2: float
flow_coefficient: float
primary_mass_flow_path: str
primary_enthalpy_flow_path: str
reversed_mass_flow_path: str
reversed_enthalpy_flow_path: str
mass_flow_parameter_path: str
gas_velocity_path: str
opening_path: str | None
@property
def is_variable(self) -> bool:
return self.opening_path is not None
def opening_series(self, results: AmesimResults) -> tuple[float, ...]:
if self.opening_path is None:
return tuple(1.0 for _ in results.times)
return tuple(results.series(self.opening_path))
def mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.primary_mass_flow_path))
def reversed_mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.reversed_mass_flow_path))
def effective_area_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(self.nominal_area_m2 * max(opening, 0.0) for opening in self.opening_series(results))
def observation_at(self, results: AmesimResults, index: int) -> TestMqlOrificeObservation:
opening = self.opening_series(results)[index]
return TestMqlOrificeObservation(
time=results.times[index],
mass_flow_kg_s=results.series(self.primary_mass_flow_path)[index] * G_PER_S_TO_KG_PER_S,
enthalpy_flow_w=results.series(self.primary_enthalpy_flow_path)[index],
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
opening=opening,
effective_area_m2=self.nominal_area_m2 * max(opening, 0.0),
)
@dataclass(frozen=True)
class TestMqlOrificeObservationCatalog:
bindings: tuple[TestMqlOrificeBinding, ...]
@property
def fixed_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNOR001")
@property
def variable_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNVO001")
def by_alias(self, alias: str) -> TestMqlOrificeBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def build_test_mql_orifice_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
assembly: TestMqlPneumaticAssembly | None = None,
) -> TestMqlOrificeObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
assembly = assembly or build_test_mql_pneumatic_assembly()
bindings = []
for alias, orifice in {
**assembly.fixed_orifices,
**assembly.variable_orifices,
}.items():
owner_variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
primary_mass_flow = _find_primary(owner_variables, signal_prefix="dm")
primary_enthalpy_flow = _find_primary(owner_variables, signal_prefix="dh")
reversed_mass_flow = _find_reversed(owner_variables, signal_prefix="dm")
reversed_enthalpy_flow = _find_reversed(owner_variables, signal_prefix="dh")
mass_flow_parameter = _find_by_signal(owner_variables, "cm")
gas_velocity = _find_by_signal(owner_variables, "gasvel")
opening = _find_optional_by_signal(owner_variables, "xv")
bindings.append(
TestMqlOrificeBinding(
alias=alias,
submodel=primary_mass_flow.submodel,
nominal_area_m2=orifice.area,
flow_coefficient=orifice.flow_coefficient,
primary_mass_flow_path=primary_mass_flow.data_path,
primary_enthalpy_flow_path=primary_enthalpy_flow.data_path,
reversed_mass_flow_path=reversed_mass_flow.data_path,
reversed_enthalpy_flow_path=reversed_enthalpy_flow.data_path,
mass_flow_parameter_path=mass_flow_parameter.data_path,
gas_velocity_path=gas_velocity.data_path,
opening_path=opening.data_path if opening is not None else None,
)
)
return TestMqlOrificeObservationCatalog(
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
)
def _find_primary(
variables: tuple[TestMqlVariableBinding, ...],
*,
signal_prefix: str,
) -> TestMqlVariableBinding:
matches = [
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and "sign reversed duplicate" not in variable.label
]
return _single(matches, f"primary {signal_prefix}")
def _find_reversed(
variables: tuple[TestMqlVariableBinding, ...],
*,
signal_prefix: str,
) -> TestMqlVariableBinding:
matches = [
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and "sign reversed duplicate" in variable.label
]
return _single(matches, f"reversed {signal_prefix}")
def _find_by_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding:
return _single(
[variable for variable in variables if variable.signal_name == signal_name],
signal_name,
)
def _find_optional_by_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding | None:
matches = [variable for variable in variables if variable.signal_name == signal_name]
if not matches:
return None
return _single(matches, signal_name)
def _single(
matches: list[TestMqlVariableBinding],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
@@ -0,0 +1,100 @@
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 (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_variables import TestMqlVariableBinding
@dataclass(frozen=True)
class TestMqlOutputSignal:
data_path: str
domain: str
owner_alias: str
owner_kind: str
submodel: str
signal_name: str
units: str | None
amesim_index: int
saved: bool
@dataclass(frozen=True)
class TestMqlOutputSchema:
signals: tuple[TestMqlOutputSignal, ...]
@property
def signal_count(self) -> int:
return len(self.signals)
def by_data_path(self, data_path: str) -> TestMqlOutputSignal:
for signal in self.signals:
if signal.data_path == data_path:
return signal
raise KeyError(data_path)
def data_paths(self) -> tuple[str, ...]:
return tuple(signal.data_path for signal in self.signals)
def data_paths_by_domain(self, domain: str) -> tuple[str, ...]:
return tuple(signal.data_path for signal in self.signals if signal.domain == domain)
def counts_by_domain(self) -> dict[str, int]:
return dict(Counter(signal.domain for signal in self.signals))
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(signal.submodel for signal in self.signals))
def counts_by_owner_kind(self) -> dict[str, int]:
return dict(Counter(signal.owner_kind for signal in self.signals))
def counts_by_units(self) -> dict[str | None, int]:
return dict(Counter(signal.units for signal in self.signals))
def build_test_mql_output_schema(
results: AmesimResults,
*,
observation_catalog: TestMqlObservationCatalog | None = None,
) -> TestMqlOutputSchema:
observation_catalog = observation_catalog or build_test_mql_observation_catalog(results)
domain_by_data_path = _domain_by_data_path(observation_catalog)
signals = []
for data_path in sorted(domain_by_data_path):
variable = observation_catalog.variable_catalog.by_data_path(data_path)
signals.append(_signal_from_variable(variable, domain_by_data_path[data_path]))
return TestMqlOutputSchema(signals=tuple(signals))
def _domain_by_data_path(
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, str]:
domain_by_data_path = {}
for domain, data_paths in observation_catalog.data_paths_by_domain().items():
for data_path in data_paths:
if data_path in domain_by_data_path:
raise ValueError(f"Data_Path {data_path!r} is assigned to multiple domains.")
domain_by_data_path[data_path] = domain
return domain_by_data_path
def _signal_from_variable(
variable: TestMqlVariableBinding,
domain: str,
) -> TestMqlOutputSignal:
return TestMqlOutputSignal(
data_path=variable.data_path,
domain=domain,
owner_alias=variable.owner_alias,
owner_kind=variable.owner_kind,
submodel=variable.submodel,
signal_name=variable.signal_name,
units=variable.units,
amesim_index=variable.index,
saved=variable.saved,
)
@@ -0,0 +1,161 @@
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 (
TestMqlComparisonResult,
compare_test_mql_series,
)
from PythonModels.reporting.test_mql_output_schema import TestMqlOutputSchema
class TestMqlOutputValidationError(ValueError):
"""Raised when a Python test_mql output does not satisfy the AMESim output contract."""
@dataclass(frozen=True)
class TestMqlValidatedOutput:
times: tuple[float, ...]
series_by_data_path: dict[str, tuple[float, ...]]
data_paths: tuple[str, ...]
def series(self, data_path: str) -> tuple[float, ...]:
if data_path not in self.series_by_data_path:
raise KeyError(data_path)
return self.series_by_data_path[data_path]
def validate_test_mql_output(
*,
times: tuple[float, ...] | list[float],
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
data_paths: tuple[str, ...] | list[str] | None = None,
allow_extra_paths: bool = False,
require_all_schema_paths: bool = False,
) -> TestMqlValidatedOutput:
validated_times = _validate_time_axis(times)
selected_paths = _select_paths(
series_by_data_path=series_by_data_path,
schema=schema,
data_paths=data_paths,
allow_extra_paths=allow_extra_paths,
require_all_schema_paths=require_all_schema_paths,
)
validated_series = {
data_path: _validate_series(
data_path=data_path,
values=series_by_data_path[data_path],
expected_count=len(validated_times),
)
for data_path in selected_paths
}
return TestMqlValidatedOutput(
times=validated_times,
series_by_data_path=validated_series,
data_paths=selected_paths,
)
def compare_validated_test_mql_output(
*,
times: tuple[float, ...] | list[float],
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
allow_extra_paths: bool = False,
require_all_schema_paths: bool = False,
relative_floor: float = 1.0e-12,
) -> TestMqlComparisonResult:
validated = validate_test_mql_output(
times=times,
series_by_data_path=series_by_data_path,
schema=schema,
data_paths=data_paths,
allow_extra_paths=allow_extra_paths,
require_all_schema_paths=require_all_schema_paths,
)
return compare_test_mql_series(
python_times=validated.times,
python_series_by_data_path=validated.series_by_data_path,
amesim_results=amesim_results,
data_paths=validated.data_paths,
relative_floor=relative_floor,
)
def _validate_time_axis(times: tuple[float, ...] | list[float]) -> tuple[float, ...]:
if not times:
raise TestMqlOutputValidationError("Python time axis is empty.")
validated = tuple(_finite_float("time", value) for value in times)
previous = validated[0]
for value in validated[1:]:
if value < previous:
raise TestMqlOutputValidationError("Python time axis must be monotonically increasing.")
previous = value
return validated
def _select_paths(
*,
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
data_paths: tuple[str, ...] | list[str] | None,
allow_extra_paths: bool,
require_all_schema_paths: bool,
) -> tuple[str, ...]:
schema_paths = set(schema.data_paths())
provided_paths = set(series_by_data_path)
if not allow_extra_paths:
extra_paths = sorted(provided_paths - schema_paths)
if extra_paths:
raise TestMqlOutputValidationError(
f"Python output contains Data_Path values outside test_mql schema: {extra_paths}"
)
if require_all_schema_paths:
missing_schema_paths = sorted(schema_paths - provided_paths)
if missing_schema_paths:
raise TestMqlOutputValidationError(
f"Python output is missing required test_mql schema Data_Path values: {missing_schema_paths}"
)
selected_paths = tuple(data_paths) if data_paths is not None else tuple(sorted(provided_paths & schema_paths))
if not selected_paths:
raise TestMqlOutputValidationError("no test_mql schema Data_Path values are available.")
unknown_selected = [data_path for data_path in selected_paths if data_path not in schema_paths]
if unknown_selected:
raise TestMqlOutputValidationError(
f"Requested Data_Path values are outside test_mql schema: {unknown_selected}"
)
missing_selected = [data_path for data_path in selected_paths if data_path not in series_by_data_path]
if missing_selected:
raise TestMqlOutputValidationError(
f"Python output is missing selected Data_Path values: {missing_selected}"
)
return selected_paths
def _validate_series(
*,
data_path: str,
values: tuple[float, ...] | list[float],
expected_count: int,
) -> tuple[float, ...]:
if len(values) != expected_count:
raise TestMqlOutputValidationError(
f"Python series length mismatch for {data_path!r}: "
f"{len(values)} values for {expected_count} time samples."
)
return tuple(_finite_float(data_path, value) for value in values)
def _finite_float(label: str, value: float) -> float:
try:
numeric_value = float(value)
except (TypeError, ValueError) as exc:
raise TestMqlOutputValidationError(f"{label!r} contains a non-numeric value: {value!r}") from exc
if not isfinite(numeric_value):
raise TestMqlOutputValidationError(f"{label!r} contains a non-finite value: {value!r}")
return numeric_value
@@ -0,0 +1,111 @@
from __future__ import annotations
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
_UNIT_RE = re.compile(r"\[([^\]]+)\]\s*$")
@dataclass(frozen=True)
class TestMqlVariableBinding:
index: int
data_path: str
signal_name: str
owner_alias: str
owner_kind: str
submodel: str
label: str
units: str | None
saved: bool
@dataclass(frozen=True)
class TestMqlVariableCatalog:
variables: tuple[TestMqlVariableBinding, ...]
@property
def data_path_count(self) -> int:
return len(self.variables)
@property
def saved_data_path_count(self) -> int:
return sum(1 for variable in self.variables if variable.saved)
def by_data_path(self, data_path: str) -> TestMqlVariableBinding:
for variable in self.variables:
if variable.data_path == data_path:
return variable
raise KeyError(data_path)
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(variable.submodel for variable in self.variables))
def counts_by_owner_kind(self) -> dict[str, int]:
return dict(Counter(variable.owner_kind for variable in self.variables))
def data_paths_for_owner(self, owner_alias: str) -> tuple[str, ...]:
return tuple(
variable.data_path
for variable in self.variables
if variable.owner_alias == owner_alias
)
def build_test_mql_variable_catalog(amesim_results: AmesimResults) -> TestMqlVariableCatalog:
owner_map = _build_owner_map()
saved_indices = set(amesim_results.saved_variable_indices)
bindings = []
for variable in amesim_results.variables:
if variable.data_path is None:
continue
signal_name, owner_alias = split_data_path(variable.data_path)
owner_kind, submodel = owner_map[owner_alias]
bindings.append(
TestMqlVariableBinding(
index=variable.index,
data_path=variable.data_path,
signal_name=signal_name,
owner_alias=owner_alias,
owner_kind=owner_kind,
submodel=submodel,
label=variable.label,
units=_extract_units(variable),
saved=variable.index in saved_indices,
)
)
return TestMqlVariableCatalog(variables=tuple(bindings))
def split_data_path(data_path: str) -> tuple[str, str]:
if "@" not in data_path:
raise ValueError(f"AMESim Data_Path does not contain an owner alias: {data_path!r}")
signal_name, owner_alias = data_path.rsplit("@", 1)
if not signal_name or not owner_alias:
raise ValueError(f"Invalid AMESim Data_Path: {data_path!r}")
return signal_name, owner_alias
def _build_owner_map() -> dict[str, tuple[str, str]]:
owner_map = {
str(spec["alias"]): ("component", str(spec["submodel"]))
for spec in COMPONENT_SPECS
}
owner_map.update(
{
str(spec["alias"]): ("connection", str(spec["submodel"]))
for spec in CONNECTION_SPECS
}
)
return owner_map
def _extract_units(variable: AmesimVariable) -> str | None:
match = _UNIT_RE.search(variable.label)
if match is None:
return None
return match.group(1)
+44 -14
View File
@@ -134,12 +134,12 @@ class PressureFlowSolver:
if not changed:
break
def _scales(self) -> tuple[float, float]:
def _scales(self) -> dict[str, float]:
pressure_scale = max(
[
abs(unknown.read())
for unknown in self.unknowns
if unknown.role == "effort" and unknown.read() > 0.0
if unknown.variable == "p" and unknown.read() > 0.0
]
+ [1e5]
)
@@ -148,16 +148,31 @@ class PressureFlowSolver:
for component in self.network.components.values()
if hasattr(component, "K_eff")
]
flow_scale = max(
mass_flow_scale = max(
estimated_flows
+ [
abs(unknown.read())
for unknown in self.unknowns
if unknown.role == "flow"
if unknown.variable == "m_flow"
]
+ [1e-3]
)
return pressure_scale, flow_scale
return {
"p": pressure_scale,
"m_flow": mass_flow_scale,
"x": max(
[abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "x"]
+ [1.0]
),
"v": max(
[abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "v"]
+ [1.0]
),
"f": max(
[abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "f"]
+ [1.0]
),
}
def solve(self) -> AlgebraicSolveDiagnostics:
try:
@@ -169,11 +184,13 @@ class PressureFlowSolver:
) from exc
self._seed_equal_pressures()
pressure_scale, flow_scale = self._scales()
scales = self._scales()
pressure_scale = scales["p"]
flow_scale = scales["m_flow"]
positive_pressures = [
unknown.read()
for unknown in self.unknowns
if unknown.role == "effort" and unknown.read() > 0.0
if unknown.variable == "p" and unknown.read() > 0.0
]
fallback_pressure = (
sum(positive_pressures) / len(positive_pressures)
@@ -182,13 +199,28 @@ class PressureFlowSolver:
)
def variable_scale(unknown: AlgebraicUnknown) -> float:
return pressure_scale if unknown.role == "effort" else flow_scale
return scales.get(unknown.variable, max(abs(unknown.read()), 1.0))
def equation_scale(equation) -> float:
variable_names = [
variable.rsplit(".", 1)[-1]
for variable in equation.variables
]
if equation.role == "flow":
return scales["f"] if "f" in variable_names else flow_scale
if equation.role == "effort":
if "x" in variable_names:
return scales["x"]
if "v" in variable_names:
return scales["v"]
return pressure_scale
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
x0 = np.asarray(
[
(
unknown.read()
if unknown.role != "effort" or unknown.read() > 0.0
if unknown.variable != "p" or unknown.read() > 0.0
else fallback_pressure
)
/ variable_scale(unknown)
@@ -198,7 +230,7 @@ class PressureFlowSolver:
)
lower = np.asarray(
[
1.0 / pressure_scale if unknown.role == "effort" else -np.inf
1.0 / pressure_scale if unknown.variable == "p" else -np.inf
for unknown in self.unknowns
]
)
@@ -213,8 +245,7 @@ class PressureFlowSolver:
equations = self.network.pressure_flow_equation_residuals()
return np.asarray(
[
equation.value
/ (pressure_scale if equation.role == "effort" else flow_scale)
equation.value / equation_scale(equation)
for equation in equations
],
dtype=float,
@@ -234,8 +265,7 @@ class PressureFlowSolver:
equations = self.network.pressure_flow_equation_residuals()
scaled = [
abs(
equation.value
/ (pressure_scale if equation.role == "effort" else flow_scale)
equation.value / equation_scale(equation)
)
for equation in equations
]
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from app.simulation.systems.network import Endpoint, SimulationNetwork
class SignalOutputComponent(Protocol):
name: str
def signal_output_values(self, time: float) -> dict[str, float]:
...
@dataclass(frozen=True)
class SignalSolveDiagnostics:
propagated: int
def as_dict(self) -> dict[str, object]:
return {"propagated": self.propagated}
class SignalResolver:
"""Propagate scalar signal connections from output ports to input ports."""
def __init__(self, network: SimulationNetwork) -> None:
self.network = network
self._connections = [
connection for connection in network.connections if connection.kind == "signal"
]
self.last_diagnostics: SignalSolveDiagnostics | None = None
def solve(self, time: float) -> SignalSolveDiagnostics:
for component in self.network.components.values():
signal_output_values = getattr(component, "signal_output_values", None)
if signal_output_values is None:
continue
for port_name, value in signal_output_values(time).items():
component.get_port(port_name).signal = float(value)
propagated = 0
for connection in self._connections:
source, target = self._source_target(connection.endpoints)
source_port = self.network.components[source.component].get_port(source.port)
target_port = self.network.components[target.component].get_port(target.port)
target_port.signal = source_port.signal
propagated += 1
diagnostics = SignalSolveDiagnostics(propagated=propagated)
self.last_diagnostics = diagnostics
return diagnostics
def _source_target(self, endpoints: tuple[Endpoint, Endpoint]) -> tuple[Endpoint, Endpoint]:
first, second = endpoints
first_port = self.network.components[first.component].get_port(first.port)
second_port = self.network.components[second.component].get_port(second.port)
if first_port.definition is not None and first_port.definition.nominal_role == "output":
return first, second
if second_port.definition is not None and second_port.definition.nominal_role == "output":
return second, first
raise ValueError("Signal connection must contain one output endpoint.")
+17 -16
View File
@@ -9,6 +9,7 @@ from app.simulation.core.base import DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
from app.simulation.solvers.signal import SignalResolver
from app.simulation.solvers.stream import StreamResolver
from app.simulation.systems.network import Endpoint, SimulationNetwork
@@ -111,18 +112,6 @@ def simulation_preparation_issues(
)
)
if any(
definition.kind == "signal"
for component in network.components.values()
for definition in component.port_definitions
):
issues.append(
SimulationPreparationIssue(
"SIGNAL_PORT_UNSUPPORTED",
"Signal-port simulation is not implemented in the current MVP solver.",
)
)
structure = network.pressure_flow_structure_dict()
if not structure["isSquare"]:
issues.append(
@@ -244,11 +233,13 @@ class GenericFluidSystem:
self.network = network
self.dynamic_components = network.dynamic_components()
self.pressure_flow_solver = PressureFlowSolver(network)
self.signal_resolver = SignalResolver(network)
self.stream_resolver = StreamResolver(network)
self.algebraic_solve_count = 0
self.max_algebraic_residual = 0.0
self.max_algebraic_evaluations = 0
self.max_stream_iterations = 0
self.signal_propagation_count = 0
def initial_state_vector(self) -> list[float]:
return self.network.initial_state_vector()
@@ -256,7 +247,9 @@ class GenericFluidSystem:
def apply_state_vector(self, values: list[float]) -> None:
self.network.apply_state_vector(values)
def _close_current_state(self) -> dict[str, dict[str, float]]:
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
signal = self.signal_resolver.solve(time)
self.signal_propagation_count += signal.propagated
for component in self.dynamic_components:
component.refresh_thermodynamic_ports()
algebraic = self.pressure_flow_solver.solve()
@@ -279,12 +272,12 @@ class GenericFluidSystem:
def consistent_initial_state_vector(self) -> list[float]:
state = self.initial_state_vector()
self.apply_state_vector(state)
self._close_current_state()
self._close_current_state(0.0)
return state
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
self.apply_state_vector(state_vector)
connected_h = self._close_current_state()
connected_h = self._close_current_state(_time)
derivatives: list[float] = []
for component in self.dynamic_components:
derivatives.extend(
@@ -395,7 +388,7 @@ class GenericFluidSystem:
]
try:
self.apply_state_vector(state)
self._close_current_state()
self._close_current_state(times[time_index])
self._append_current_state(series)
series["time"].append(times[time_index])
except Exception as exc:
@@ -433,6 +426,14 @@ class GenericFluidSystem:
else None
),
},
"signal": {
"propagations": self.signal_propagation_count,
"last": (
self.signal_resolver.last_diagnostics.as_dict()
if self.signal_resolver.last_diagnostics is not None
else None
),
},
"stateCount": len(initial_state),
"sampleCount": len(series["time"]),
}