Merge model-development into main
This commit is contained in:
commit
127ec36a55
218 files changed
+65243
-47
No files matched your search
+43
@@ -388,6 +388,15 @@ def simulate_reactflow_testmodel(payload: ReactFlowProjectPayload) -> dict[str,
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/reactflow/simulate-test-mql")
|
||||
def simulate_reactflow_test_mql(payload: ReactFlowProjectPayload) -> dict[str, object]:
|
||||
try:
|
||||
result = run_reactflow_test_mql(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/reactflow/compile-model")
|
||||
def compile_reactflow_model(payload: ReactFlowProjectPayload) -> dict[str, object]:
|
||||
try:
|
||||
@@ -1249,6 +1258,40 @@ def run_reactflow_testmodel(project: ReactFlowProjectPayload) -> dict[str, objec
|
||||
}
|
||||
|
||||
|
||||
def run_reactflow_test_mql(project: ReactFlowProjectPayload) -> dict[str, object]:
|
||||
from app.simulation.examples.test_mql.run import run_test_mql
|
||||
from PythonModels.systems.test_mql import TestMqlRunConfig
|
||||
|
||||
run_config = TestMqlRunConfig(
|
||||
t_start=project.simulation.t_start,
|
||||
t_stop=project.simulation.t_stop,
|
||||
sample_step=project.simulation.step,
|
||||
)
|
||||
result = run_test_mql(run_config=run_config)
|
||||
series = {
|
||||
key: [float(value) for value in values]
|
||||
for key, values in result.result.series.items()
|
||||
}
|
||||
final = {key: values[-1] for key, values in series.items() if values}
|
||||
snapshot = result.system.snapshot()
|
||||
return {
|
||||
"success": True,
|
||||
"message": "test_mql fixed-topology simulation completed.",
|
||||
"model": {
|
||||
"name": snapshot.model_name,
|
||||
"componentCount": snapshot.component_count,
|
||||
"connectionCount": snapshot.connection_count,
|
||||
"continuousStateCount": snapshot.continuous_state_count,
|
||||
"discreteStateCount": snapshot.discrete_state_count,
|
||||
},
|
||||
"final": final,
|
||||
"series": series,
|
||||
"artifacts": {
|
||||
"summary": str(result.summary_path),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def first_node(
|
||||
nodes_by_type: dict[str, list[ReactFlowNodePayload]],
|
||||
model_type: str,
|
||||
|
||||
+46
-12
@@ -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")
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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_]*")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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.")
|
||||
@@ -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"]),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user